mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
feat(ui): logs, kb, emcn (#2207)
* feat(kb): emcn alignment; sidebar: popover primary; settings-modal: expand * feat: EMCN breadcrumb; improvement(KB): UI * fix: hydration error * improvement(KB): UI * feat: emcn modal sizing, KB tags; refactor: deleted old sidebar * feat(logs): UI * fix: add documents modal name * feat: logs, emcn, cursorrules; refactor: logs * feat: dashboard * feat: notifications; improvement: logs details * fixed random rectangle on canvas * fixed the name of the file to align * fix build --------- Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
45
.cursor/rules/emcn-components.mdc
Normal file
45
.cursor/rules/emcn-components.mdc
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
description: EMCN component library patterns with CVA
|
||||
globs: ["apps/sim/components/emcn/**"]
|
||||
---
|
||||
|
||||
# EMCN Component Guidelines
|
||||
|
||||
## When to Use CVA vs Direct Styles
|
||||
|
||||
**Use CVA (class-variance-authority) when:**
|
||||
- 2+ visual variants (primary, secondary, outline)
|
||||
- Multiple sizes or state variations
|
||||
- Example: Button with variants
|
||||
|
||||
**Use direct className when:**
|
||||
- Single consistent style
|
||||
- No variations needed
|
||||
- Example: Label with one style
|
||||
|
||||
## Patterns
|
||||
|
||||
**With CVA:**
|
||||
```tsx
|
||||
const buttonVariants = cva('base-classes', {
|
||||
variants: {
|
||||
variant: { default: '...', primary: '...' },
|
||||
size: { sm: '...', md: '...' }
|
||||
}
|
||||
})
|
||||
export { Button, buttonVariants }
|
||||
```
|
||||
|
||||
**Without CVA:**
|
||||
```tsx
|
||||
function Label({ className, ...props }) {
|
||||
return <Primitive className={cn('single-style-classes', className)} {...props} />
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
- Use Radix UI primitives for accessibility
|
||||
- Export component and variants (if using CVA)
|
||||
- TSDoc with usage examples
|
||||
- Consistent tokens: `font-medium`, `text-[12px]`, `rounded-[4px]`
|
||||
- Always use `transition-colors` for hover states
|
||||
20
.cursor/rules/global.mdc
Normal file
20
.cursor/rules/global.mdc
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description: Global coding standards that apply to all files
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Global Standards
|
||||
|
||||
You are a professional software engineer. All code must follow best practices: accurate, readable, clean, and efficient.
|
||||
|
||||
## Logging
|
||||
Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`.
|
||||
|
||||
## Comments
|
||||
Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
|
||||
|
||||
## Styling
|
||||
Never update global styles. Keep all styling local to components.
|
||||
|
||||
## Package Manager
|
||||
Use `bun` and `bunx`, not `npm` and `npx`.
|
||||
67
.cursor/rules/sim-architecture.mdc
Normal file
67
.cursor/rules/sim-architecture.mdc
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
description: Core architecture principles for the Sim app
|
||||
globs: ["apps/sim/**"]
|
||||
---
|
||||
|
||||
# Sim App Architecture
|
||||
|
||||
## Core Principles
|
||||
1. **Single Responsibility**: Each component, hook, store has one clear purpose
|
||||
2. **Composition Over Complexity**: Break down complex logic into smaller pieces
|
||||
3. **Type Safety First**: TypeScript interfaces for all props, state, return types
|
||||
4. **Predictable State**: Zustand for global state, useState for UI-only concerns
|
||||
5. **Performance by Default**: useMemo, useCallback, refs appropriately
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
feature/
|
||||
├── components/ # Feature components
|
||||
│ └── sub-feature/ # Sub-feature with own components
|
||||
├── hooks/ # Custom hooks
|
||||
└── feature.tsx # Main component
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
- **Components**: PascalCase (`WorkflowList`, `TriggerPanel`)
|
||||
- **Hooks**: camelCase with `use` prefix (`useWorkflowOperations`)
|
||||
- **Files**: kebab-case matching export (`workflow-list.tsx`)
|
||||
- **Stores**: kebab-case in stores/ (`sidebar/store.ts`)
|
||||
- **Constants**: SCREAMING_SNAKE_CASE
|
||||
- **Interfaces**: PascalCase with suffix (`WorkflowListProps`)
|
||||
|
||||
## State Management
|
||||
|
||||
**useState**: UI-only concerns (dropdown open, hover, form inputs)
|
||||
**Zustand**: Shared state, persistence, global app state
|
||||
**useRef**: DOM refs, avoiding dependency issues, mutable non-reactive values
|
||||
|
||||
## Component Extraction
|
||||
|
||||
**Extract to separate file when:**
|
||||
- Complex (50+ lines)
|
||||
- Used across 2+ files
|
||||
- Has own state/logic
|
||||
|
||||
**Keep inline when:**
|
||||
- Simple (< 10 lines)
|
||||
- Used in only 1 file
|
||||
- Purely presentational
|
||||
|
||||
**Never import utilities from another component file.** Extract shared helpers to `lib/` or `utils/`.
|
||||
|
||||
## Utils Files
|
||||
|
||||
**Never create a `utils.ts` file for a single consumer.** Inline the logic directly in the consuming component.
|
||||
|
||||
**Create `utils.ts` when:**
|
||||
- 2+ files import the same helper
|
||||
|
||||
**Prefer existing sources of truth:**
|
||||
- Before duplicating logic, check if a centralized helper already exists (e.g., `lib/logs/get-trigger-options.ts`)
|
||||
- Import from the source of truth rather than creating wrapper functions
|
||||
|
||||
**Location hierarchy:**
|
||||
- `lib/` — App-wide utilities (auth, billing, core)
|
||||
- `feature/utils.ts` — Feature-scoped utilities (used by 2+ components in the feature)
|
||||
- Inline — Single-use helpers (define directly in the component)
|
||||
64
.cursor/rules/sim-components.mdc
Normal file
64
.cursor/rules/sim-components.mdc
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
description: Component patterns and structure for React components
|
||||
globs: ["apps/sim/**/*.tsx"]
|
||||
---
|
||||
|
||||
# Component Patterns
|
||||
|
||||
## Structure Order
|
||||
```typescript
|
||||
'use client' // Only if using hooks
|
||||
|
||||
// 1. Imports (external → internal → relative)
|
||||
// 2. Constants at module level
|
||||
const CONFIG = { SPACING: 8 } as const
|
||||
|
||||
// 3. Props interface with TSDoc
|
||||
interface ComponentProps {
|
||||
/** Description */
|
||||
requiredProp: string
|
||||
optionalProp?: boolean
|
||||
}
|
||||
|
||||
// 4. Component with TSDoc
|
||||
export function Component({ requiredProp, optionalProp = false }: ComponentProps) {
|
||||
// a. Refs
|
||||
// b. External hooks (useParams, useRouter)
|
||||
// c. Store hooks
|
||||
// d. Custom hooks
|
||||
// e. Local state
|
||||
// f. useMemo computations
|
||||
// g. useCallback handlers
|
||||
// h. useEffect
|
||||
// i. Return JSX
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
1. Add `'use client'` when using React hooks
|
||||
2. Always define props interface
|
||||
3. TSDoc on component: description, @param, @returns
|
||||
4. Extract constants with `as const`
|
||||
5. Use Tailwind only, no inline styles
|
||||
6. Semantic HTML (`aside`, `nav`, `article`)
|
||||
7. Include ARIA attributes where appropriate
|
||||
8. Optional chain callbacks: `onAction?.(id)`
|
||||
|
||||
## Factory Pattern with Caching
|
||||
|
||||
When generating components for a specific signature (e.g., icons):
|
||||
|
||||
```typescript
|
||||
const cache = new Map<string, React.ComponentType<{ className?: string }>>()
|
||||
|
||||
function getColorIcon(color: string) {
|
||||
if (cache.has(color)) return cache.get(color)!
|
||||
|
||||
const Icon = ({ className }: { className?: string }) => (
|
||||
<div className={cn(className, 'rounded-[3px]')} style={{ backgroundColor: color, width: 10, height: 10 }} />
|
||||
)
|
||||
Icon.displayName = `ColorIcon(${color})`
|
||||
cache.set(color, Icon)
|
||||
return Icon
|
||||
}
|
||||
```
|
||||
68
.cursor/rules/sim-hooks.mdc
Normal file
68
.cursor/rules/sim-hooks.mdc
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
description: Custom hook patterns and best practices
|
||||
globs: ["apps/sim/**/use-*.ts", "apps/sim/**/hooks/**/*.ts"]
|
||||
---
|
||||
|
||||
# Hook Patterns
|
||||
|
||||
## Structure
|
||||
```typescript
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('useFeatureName')
|
||||
|
||||
interface UseFeatureProps {
|
||||
id: string
|
||||
onSuccess?: (result: Result) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook description.
|
||||
* @param props - Configuration
|
||||
* @returns State and operations
|
||||
*/
|
||||
export function useFeature({ id, onSuccess }: UseFeatureProps) {
|
||||
// 1. Refs for stable dependencies
|
||||
const idRef = useRef(id)
|
||||
const onSuccessRef = useRef(onSuccess)
|
||||
|
||||
// 2. State
|
||||
const [data, setData] = useState<Data | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
// 3. Sync refs
|
||||
useEffect(() => {
|
||||
idRef.current = id
|
||||
onSuccessRef.current = onSuccess
|
||||
}, [id, onSuccess])
|
||||
|
||||
// 4. Operations with useCallback
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await fetch(`/api/${idRef.current}`).then(r => r.json())
|
||||
setData(result)
|
||||
onSuccessRef.current?.(result)
|
||||
} catch (err) {
|
||||
setError(err as Error)
|
||||
logger.error('Failed', { error: err })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, []) // Empty deps - using refs
|
||||
|
||||
// 5. Return grouped by state/operations
|
||||
return { data, isLoading, error, fetchData }
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
1. Single responsibility per hook
|
||||
2. Props interface required
|
||||
3. TSDoc required
|
||||
4. Use logger, not console.log
|
||||
5. Refs for stable callback dependencies
|
||||
6. Wrap returned functions in useCallback
|
||||
7. Always try/catch async operations
|
||||
8. Track loading/error states
|
||||
37
.cursor/rules/sim-imports.mdc
Normal file
37
.cursor/rules/sim-imports.mdc
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
description: Import patterns for the Sim application
|
||||
globs: ["apps/sim/**/*.ts", "apps/sim/**/*.tsx"]
|
||||
---
|
||||
|
||||
# Import Patterns
|
||||
|
||||
## EMCN Components
|
||||
Import from `@/components/emcn`, never from subpaths like `@/components/emcn/components/modal/modal`.
|
||||
|
||||
**Exception**: CSS imports use actual file paths: `import '@/components/emcn/components/code/code.css'`
|
||||
|
||||
## Feature Components
|
||||
Import from central folder indexes, not specific subfolders:
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
import { Dashboard, Sidebar } from '@/app/workspace/[workspaceId]/logs/components'
|
||||
|
||||
// ❌ Wrong
|
||||
import { Dashboard } from '@/app/workspace/[workspaceId]/logs/components/dashboard'
|
||||
```
|
||||
|
||||
## Internal vs External
|
||||
- **Cross-feature**: Absolute paths through central index
|
||||
- **Within feature**: Relative paths (`./components/...`, `../utils`)
|
||||
|
||||
## Import Order
|
||||
1. React/core libraries
|
||||
2. External libraries
|
||||
3. UI components (`@/components/emcn`, `@/components/ui`)
|
||||
4. Utilities (`@/lib/...`)
|
||||
5. Feature imports from indexes
|
||||
6. Relative imports
|
||||
7. CSS imports
|
||||
|
||||
## Types
|
||||
Use `type` keyword: `import type { WorkflowLog } from '...'`
|
||||
57
.cursor/rules/sim-stores.mdc
Normal file
57
.cursor/rules/sim-stores.mdc
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
description: Zustand store patterns
|
||||
globs: ["apps/sim/**/store.ts", "apps/sim/**/stores/**/*.ts"]
|
||||
---
|
||||
|
||||
# Zustand Store Patterns
|
||||
|
||||
## Structure
|
||||
```typescript
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
interface FeatureState {
|
||||
// State
|
||||
items: Item[]
|
||||
activeId: string | null
|
||||
|
||||
// Actions
|
||||
setItems: (items: Item[]) => void
|
||||
addItem: (item: Item) => void
|
||||
clearState: () => void
|
||||
}
|
||||
|
||||
const createInitialState = () => ({
|
||||
items: [],
|
||||
activeId: null,
|
||||
})
|
||||
|
||||
export const useFeatureStore = create<FeatureState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...createInitialState(),
|
||||
|
||||
setItems: (items) => set({ items }),
|
||||
|
||||
addItem: (item) => set((state) => ({
|
||||
items: [...state.items, item],
|
||||
})),
|
||||
|
||||
clearState: () => set(createInitialState()),
|
||||
}),
|
||||
{
|
||||
name: 'feature-state',
|
||||
partialize: (state) => ({ items: state.items }),
|
||||
}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## Rules
|
||||
1. Interface includes state and actions
|
||||
2. Extract config to module constants
|
||||
3. TSDoc on store
|
||||
4. Only persist what's needed
|
||||
5. Immutable updates only - never mutate
|
||||
6. Use `set((state) => ...)` when depending on previous state
|
||||
7. Provide clear/reset actions
|
||||
47
.cursor/rules/sim-styling.mdc
Normal file
47
.cursor/rules/sim-styling.mdc
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
description: Tailwind CSS and styling conventions
|
||||
globs: ["apps/sim/**/*.tsx", "apps/sim/**/*.css"]
|
||||
---
|
||||
|
||||
# Styling Rules
|
||||
|
||||
## Tailwind
|
||||
1. **No inline styles** - Use Tailwind classes exclusively
|
||||
2. **No duplicate dark classes** - Don't add `dark:` when value matches light mode
|
||||
3. **Exact values** - Use design system values (`text-[14px]`, `h-[25px]`)
|
||||
4. **Prefer px** - Use `px-[4px]` over `px-1`
|
||||
5. **Transitions** - Add `transition-colors` for interactive states
|
||||
|
||||
## Conditional Classes
|
||||
```typescript
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
<div className={cn(
|
||||
'base-classes',
|
||||
isActive && 'active-classes',
|
||||
disabled ? 'opacity-60' : 'hover:bg-accent'
|
||||
)} />
|
||||
```
|
||||
|
||||
## CSS Variables for Dynamic Styles
|
||||
```typescript
|
||||
// In store setter
|
||||
setSidebarWidth: (width) => {
|
||||
set({ sidebarWidth: width })
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${width}px`)
|
||||
}
|
||||
|
||||
// In component
|
||||
<aside style={{ width: 'var(--sidebar-width)' }} />
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
```typescript
|
||||
// ❌ Bad
|
||||
<div style={{ width: 200 }}>
|
||||
<div className='text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
|
||||
// ✅ Good
|
||||
<div className='w-[200px]'>
|
||||
<div className='text-[var(--text-primary)]'>
|
||||
```
|
||||
24
.cursor/rules/sim-typescript.mdc
Normal file
24
.cursor/rules/sim-typescript.mdc
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
description: TypeScript conventions and type safety
|
||||
globs: ["apps/sim/**/*.ts", "apps/sim/**/*.tsx"]
|
||||
---
|
||||
|
||||
# TypeScript Rules
|
||||
|
||||
1. **No `any`** - Use proper types or `unknown` with type guards
|
||||
2. **Props interface** - Always define, even for simple components
|
||||
3. **Callback types** - Full signature with params and return type
|
||||
4. **Generics** - Use for reusable components/hooks
|
||||
5. **Const assertions** - `as const` for constant objects/arrays
|
||||
6. **Ref types** - Explicit: `useRef<HTMLDivElement>(null)`
|
||||
|
||||
## Anti-Patterns
|
||||
```typescript
|
||||
// ❌ Bad
|
||||
const handleClick = (e: any) => {}
|
||||
useEffect(() => { doSomething(prop) }, []) // Missing dep
|
||||
|
||||
// ✅ Good
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {}
|
||||
useEffect(() => { doSomething(prop) }, [prop])
|
||||
```
|
||||
19
.cursorrules
19
.cursorrules
@@ -1,19 +0,0 @@
|
||||
# Role
|
||||
|
||||
You are a professional software engineer. All code you write MUST follow best practices, ensuring accuracy, quality, readability, and cleanliness. You MUST make FOCUSED EDITS that are EFFICIENT and ELEGANT.
|
||||
|
||||
## Logs
|
||||
|
||||
ENSURE that you use the logger.info and logger.warn and logger.error instead of the console.log whenever you want to display logs.
|
||||
|
||||
## Comments
|
||||
|
||||
You must use TSDOC for comments. Do not use ==== for comments to separate sections. Do not leave any comments that are not TSDOC.
|
||||
|
||||
## Globals styles
|
||||
|
||||
You should not update the global styles unless it is absolutely necessary. Keep all styling local to components and files.
|
||||
|
||||
## Bun
|
||||
|
||||
Use bun and bunx not npm and npx
|
||||
47
CLAUDE.md
Normal file
47
CLAUDE.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Expert Programming Standards
|
||||
|
||||
**You are tasked with implementing solutions that follow best practices. You MUST be accurate, elegant, and efficient as an expert programmer.**
|
||||
|
||||
---
|
||||
|
||||
# Role
|
||||
|
||||
You are a professional software engineer. All code you write MUST follow best practices, ensuring accuracy, quality, readability, and cleanliness. You MUST make FOCUSED EDITS that are EFFICIENT and ELEGANT.
|
||||
|
||||
## Logs
|
||||
|
||||
ENSURE that you use the logger.info and logger.warn and logger.error instead of the console.log whenever you want to display logs.
|
||||
|
||||
## Comments
|
||||
|
||||
You must use TSDOC for comments. Do not use ==== for comments to separate sections. Do not leave any comments that are not TSDOC.
|
||||
|
||||
## Global Styles
|
||||
|
||||
You should not update the global styles unless it is absolutely necessary. Keep all styling local to components and files.
|
||||
|
||||
## Bun
|
||||
|
||||
Use bun and bunx not npm and npx.
|
||||
|
||||
## Code Quality
|
||||
|
||||
- Write clean, maintainable code that follows the project's existing patterns
|
||||
- Prefer composition over inheritance
|
||||
- Keep functions small and focused on a single responsibility
|
||||
- Use meaningful variable and function names
|
||||
- Handle errors gracefully and provide useful error messages
|
||||
- Write type-safe code with proper TypeScript types
|
||||
|
||||
## Testing
|
||||
|
||||
- Write tests for new functionality when appropriate
|
||||
- Ensure existing tests pass before completing work
|
||||
- Follow the project's testing conventions
|
||||
|
||||
## Performance
|
||||
|
||||
- Consider performance implications of your code
|
||||
- Avoid unnecessary re-renders in React components
|
||||
- Use appropriate data structures and algorithms
|
||||
- Profile and optimize when necessary
|
||||
@@ -1,777 +0,0 @@
|
||||
# Sim App Architecture Guidelines
|
||||
|
||||
You are building features in the Sim app following the architecture. This file defines the patterns, structures, and conventions you must follow.
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Single Responsibility Principle**: Each component, hook, and store should have one clear purpose
|
||||
2. **Composition Over Complexity**: Break down complex logic into smaller, composable pieces
|
||||
3. **Type Safety First**: Use TypeScript interfaces for all props, state, and return types
|
||||
4. **Predictable State Management**: Use Zustand for global state, local state for UI-only concerns
|
||||
5. **Performance by Default**: Use useMemo, useCallback, and refs appropriately
|
||||
6. **Accessibility**: Include semantic HTML and ARIA attributes
|
||||
7. **Documentation**: Use TSDoc for all public interfaces and keep it in sync with code changes
|
||||
|
||||
---
|
||||
|
||||
## File Organization
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
feature/
|
||||
├── components/ # Feature components
|
||||
│ ├── sub-feature/ # Sub-feature with its own components
|
||||
│ │ ├── component.tsx
|
||||
│ │ └── index.ts
|
||||
├── hooks/ # Custom hooks for feature logic
|
||||
│ ├── use-feature-logic.ts
|
||||
│ └── use-another-hook.ts
|
||||
└── feature.tsx # Main feature component
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Components**: PascalCase with descriptive names (`WorkflowList`, `TriggerPanel`)
|
||||
- **Hooks**: camelCase with `use` prefix (`useWorkflowOperations`, `usePanelResize`)
|
||||
- **Files**: kebab-case matching export name (`workflow-list.tsx`, `use-panel-resize.ts`)
|
||||
- **Stores**: kebab-case in stores/ directory (`sidebar/store.ts`, `workflows/registry/store.ts`)
|
||||
- **Constants**: SCREAMING_SNAKE_CASE at module level
|
||||
- **Interfaces**: PascalCase with descriptive suffix (`WorkflowListProps`, `UseWorkspaceManagementProps`)
|
||||
|
||||
---
|
||||
|
||||
## Component Architecture
|
||||
|
||||
### Component Structure Template
|
||||
|
||||
```typescript
|
||||
'use client' // Only if using hooks like useState, useEffect, etc.
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
// Other imports organized: external, internal paths, relative
|
||||
|
||||
/**
|
||||
* Constants - Define at module level before component
|
||||
*/
|
||||
const DEFAULT_VALUE = 100
|
||||
const MIN_VALUE = 50
|
||||
const MAX_VALUE = 200
|
||||
|
||||
const CONFIG = {
|
||||
SPACING: 8,
|
||||
ITEM_HEIGHT: 25,
|
||||
} as const
|
||||
|
||||
interface ComponentProps {
|
||||
/** Description of prop */
|
||||
requiredProp: string
|
||||
/** Description with default noted */
|
||||
optionalProp?: boolean
|
||||
onAction?: (id: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Component description explaining purpose and key features.
|
||||
* Mention important integrations, hooks, or patterns used.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns JSX description
|
||||
*/
|
||||
export function ComponentName({
|
||||
requiredProp,
|
||||
optionalProp = false,
|
||||
onAction,
|
||||
}: ComponentProps) {
|
||||
// 1. Refs first
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 2. External hooks (router, params, context)
|
||||
const params = useParams()
|
||||
|
||||
// 3. Store hooks
|
||||
const { state, actions } = useStore()
|
||||
|
||||
// 4. Custom hooks (your feature hooks)
|
||||
const { data, isLoading } = useCustomHook({ requiredProp })
|
||||
|
||||
// 5. Local state (UI-only concerns)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
// 6. Derived/computed values with useMemo
|
||||
const filteredData = useMemo(() => {
|
||||
return data.filter(item => item.active)
|
||||
}, [data])
|
||||
|
||||
// 7. Callbacks with useCallback
|
||||
const handleClick = useCallback((id: string) => {
|
||||
onAction?.(id)
|
||||
}, [onAction])
|
||||
|
||||
// 8. Effects
|
||||
useEffect(() => {
|
||||
// Setup logic
|
||||
return () => {
|
||||
// Cleanup logic
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 9. Render helpers (if complex)
|
||||
const renderItem = useCallback((item: Item) => (
|
||||
<div key={item.id}>{item.name}</div>
|
||||
), [])
|
||||
|
||||
// 10. Return JSX
|
||||
return (
|
||||
<div ref={containerRef} className='...' aria-label='...'>
|
||||
{/* Section comments for clarity */}
|
||||
{/* Header */}
|
||||
<header>...</header>
|
||||
|
||||
{/* Content */}
|
||||
<main>...</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Component Rules
|
||||
|
||||
1. **Client Components**: Add `'use client'` directive when using React hooks
|
||||
2. **Props Interface**: Always define TypeScript interface, even for simple props
|
||||
3. **TSDoc Required and Up-to-Date**: Include description, @param, and @returns. Update TSDoc whenever props, behavior, or side effects change (including additions and deletions).
|
||||
4. **Constants**: Extract magic numbers and config to module-level constants using `as const`
|
||||
5. **No Inline Styles**: Use Tailwind classes exclusively (CSS variables for dynamic values)
|
||||
6. **Section Comments**: Use comments to mark logical sections of JSX
|
||||
7. **Semantic HTML**: Use appropriate elements (`aside`, `nav`, `article`, etc.)
|
||||
8. **ARIA Attributes**: Include `aria-label`, `aria-orientation`, `role` where appropriate
|
||||
9. **Refs for DOM**: Use refs for direct DOM access, not state
|
||||
10. **Callback Props**: Always use optional chaining for callback props (`onAction?.(...)`)
|
||||
|
||||
---
|
||||
|
||||
## Custom Hooks Architecture
|
||||
|
||||
### Hook Structure Template
|
||||
|
||||
```typescript
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('useFeatureName')
|
||||
|
||||
/**
|
||||
* Constants specific to this hook
|
||||
*/
|
||||
const DEFAULT_CONFIG = {
|
||||
timeout: 1000,
|
||||
retries: 3,
|
||||
} as const
|
||||
|
||||
interface UseFeatureNameProps {
|
||||
/** Description of required prop */
|
||||
id: string
|
||||
/** Optional callback fired on success */
|
||||
onSuccess?: (result: Result) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to [clear description of purpose].
|
||||
* [Additional context about what it manages or coordinates].
|
||||
*
|
||||
* @param props - Configuration object containing id and callbacks
|
||||
* @returns Feature state and operations
|
||||
*/
|
||||
export function useFeatureName({ id, onSuccess }: UseFeatureNameProps) {
|
||||
// 1. Refs (to avoid dependency issues)
|
||||
const idRef = useRef(id)
|
||||
const onSuccessRef = useRef(onSuccess)
|
||||
|
||||
// 2. State
|
||||
const [data, setData] = useState<Data | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
// 3. Update refs when values change
|
||||
useEffect(() => {
|
||||
idRef.current = id
|
||||
onSuccessRef.current = onSuccess
|
||||
}, [id, onSuccess])
|
||||
|
||||
// 4. Operations with useCallback (stable references)
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetch(`/api/data/${idRef.current}`)
|
||||
const result = await response.json()
|
||||
setData(result)
|
||||
onSuccessRef.current?.(result)
|
||||
logger.info('Data fetched successfully', { id: idRef.current })
|
||||
} catch (err) {
|
||||
const error = err as Error
|
||||
setError(error)
|
||||
logger.error('Failed to fetch data', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, []) // Empty deps because using refs
|
||||
|
||||
const updateData = useCallback(async (newData: Partial<Data>) => {
|
||||
try {
|
||||
logger.info('Updating data', { id: idRef.current, newData })
|
||||
const response = await fetch(`/api/data/${idRef.current}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newData),
|
||||
})
|
||||
const result = await response.json()
|
||||
setData(result)
|
||||
return true
|
||||
} catch (err) {
|
||||
logger.error('Failed to update data', { error: err })
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 5. Effects
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchData()
|
||||
}
|
||||
}, [id, fetchData])
|
||||
|
||||
// 6. Return object - group by state and operations
|
||||
return {
|
||||
// State
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Operations
|
||||
fetchData,
|
||||
updateData,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hook Rules
|
||||
|
||||
1. **Single Responsibility**: Each hook manages one concern (data fetching, resize, navigation)
|
||||
2. **Props Interface**: Define TypeScript interface for all parameters
|
||||
3. **TSDoc Required and Up-to-Date**: Include clear description, @param, and @returns. Update TSDoc whenever inputs, outputs, behavior, or side effects change (including additions and deletions).
|
||||
4. **Logger Usage**: Import and use logger instead of console.log
|
||||
5. **Refs for Stable Deps**: Use refs to avoid recreating callbacks unnecessarily
|
||||
6. **useCallback Always**: Wrap all returned functions in useCallback
|
||||
7. **Grouped Returns**: Return object with comments separating State and Operations
|
||||
8. **Error Handling**: Always try/catch async operations and log errors
|
||||
9. **Loading States**: Track loading, error states for async operations
|
||||
10. **Dependency Arrays**: Be explicit and correct with all dependency arrays
|
||||
|
||||
---
|
||||
|
||||
## Store Architecture (Zustand)
|
||||
|
||||
### Store Structure Template
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
/**
|
||||
* Store state interface
|
||||
*/
|
||||
interface FeatureState {
|
||||
// State properties
|
||||
items: Item[]
|
||||
activeId: string | null
|
||||
isLoading: boolean
|
||||
|
||||
// Actions
|
||||
setItems: (items: Item[]) => void
|
||||
setActiveId: (id: string | null) => void
|
||||
addItem: (item: Item) => void
|
||||
removeItem: (id: string) => void
|
||||
clearState: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Constants - Configuration values
|
||||
*/
|
||||
const DEFAULT_CONFIG = {
|
||||
maxItems: 100,
|
||||
cacheTime: 3600,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Initial state factory
|
||||
*/
|
||||
const createInitialState = () => ({
|
||||
items: [],
|
||||
activeId: null,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* Feature store managing [description].
|
||||
* [Additional context about what this store coordinates].
|
||||
*/
|
||||
export const useFeatureStore = create<FeatureState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...createInitialState(),
|
||||
|
||||
setItems: (items) => set({ items }),
|
||||
|
||||
setActiveId: (id) => set({ activeId: id }),
|
||||
|
||||
addItem: (item) =>
|
||||
set((state) => ({
|
||||
items: [...state.items, item].slice(-DEFAULT_CONFIG.maxItems),
|
||||
})),
|
||||
|
||||
removeItem: (id) =>
|
||||
set((state) => ({
|
||||
items: state.items.filter((item) => item.id !== id),
|
||||
activeId: state.activeId === id ? null : state.activeId,
|
||||
})),
|
||||
|
||||
clearState: () => set(createInitialState()),
|
||||
}),
|
||||
{
|
||||
name: 'feature-state',
|
||||
// Optionally customize what to persist
|
||||
partialize: (state) => ({
|
||||
items: state.items,
|
||||
activeId: state.activeId,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
// Validate and transform persisted state if needed
|
||||
if (state) {
|
||||
// Enforce constraints
|
||||
if (state.items.length > DEFAULT_CONFIG.maxItems) {
|
||||
state.items = state.items.slice(-DEFAULT_CONFIG.maxItems)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Store Rules
|
||||
|
||||
1. **Interface First**: Define TypeScript interface including both state and actions
|
||||
2. **Constants**: Extract configuration values to module-level constants
|
||||
3. **TSDoc on Store**: Document what the store manages
|
||||
4. **Persist Strategically**: Only persist what's needed across sessions
|
||||
5. **Validation**: Use onRehydrateStorage to validate persisted state
|
||||
6. **Immutable Updates**: Use set() with new objects/arrays, never mutate
|
||||
7. **Derived State**: Use getters or selectors, not stored computed values
|
||||
8. **CSS Variables**: Update CSS variables in setters for hydration-safe dynamic styles
|
||||
9. **Cleanup Actions**: Provide clear/reset actions for state cleanup
|
||||
10. **Functional Updates**: Use `set((state) => ...)` when new state depends on old state
|
||||
|
||||
---
|
||||
|
||||
## State Management Strategy
|
||||
|
||||
### When to Use Local State (useState)
|
||||
|
||||
- UI-only concerns (dropdown open, hover states, form inputs)
|
||||
- Component-scoped state not needed elsewhere
|
||||
- Temporary state that doesn't need persistence
|
||||
|
||||
### When to Use Zustand Store
|
||||
|
||||
- Shared state across multiple components
|
||||
- State that needs persistence (localStorage)
|
||||
- Global application state (user, theme, settings)
|
||||
- Complex state with many actions/reducers
|
||||
|
||||
### When to Use Refs (useRef)
|
||||
|
||||
- DOM element references
|
||||
- Avoiding dependency issues in hooks
|
||||
- Storing mutable values that don't trigger re-renders
|
||||
- Accessing latest props/state in callbacks without recreating them
|
||||
|
||||
---
|
||||
|
||||
## CSS and Styling
|
||||
|
||||
### CSS Variables for Dynamic Styles
|
||||
|
||||
Use CSS variables for values that need to persist across hydration:
|
||||
|
||||
```typescript
|
||||
// In store setter
|
||||
setSidebarWidth: (width) => {
|
||||
const clampedWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width))
|
||||
set({ sidebarWidth: clampedWidth })
|
||||
|
||||
// Update CSS variable for immediate visual feedback
|
||||
if (typeof window !== 'undefined') {
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${clampedWidth}px`)
|
||||
}
|
||||
}
|
||||
|
||||
// In component
|
||||
<aside
|
||||
className='sidebar-container'
|
||||
style={{ width: 'var(--sidebar-width)' }}
|
||||
>
|
||||
```
|
||||
|
||||
### Tailwind Classes
|
||||
|
||||
1. **No Inline Styles**: Use Tailwind utility classes exclusively
|
||||
2. **Dark Mode**: Include dark mode variants only when the value differs from light mode
|
||||
3. **No Duplicate Dark Classes**: Never add a `dark:` class when the value is identical to the light mode class (e.g., `text-[var(--text-primary)] dark:text-[var(--text-primary)]` is redundant - just use `text-[var(--text-primary)]`)
|
||||
4. **Exact Values**: Use exact values from design system (`text-[14px]`, `h-[25px]`)
|
||||
5. **cn for Conditionals**: Use `cn()` from `@/lib/utils` for conditional classes (wraps clsx + tailwind-merge for conflict resolution)
|
||||
6. **Consistent Spacing**: Use spacing tokens (`gap-[8px]`, `px-[14px]`)
|
||||
7. **Transitions**: Add transitions for interactive states (`transition-colors`)
|
||||
8. **Prefer px units**: Use arbitrary px values over scale utilities (e.g., `px-[4px]` instead of `px-1`)
|
||||
|
||||
```typescript
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'base-classes that-always-apply',
|
||||
isActive && 'active-state-classes',
|
||||
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TypeScript Patterns
|
||||
|
||||
### Interface Conventions
|
||||
|
||||
```typescript
|
||||
// Component props
|
||||
interface ComponentNameProps {
|
||||
requiredProp: string
|
||||
optionalProp?: boolean
|
||||
}
|
||||
|
||||
// Hook props
|
||||
interface UseHookNameProps {
|
||||
id: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// Store state
|
||||
interface FeatureState {
|
||||
data: Data[]
|
||||
isLoading: boolean
|
||||
actions: () => void
|
||||
}
|
||||
|
||||
// Return types (if complex)
|
||||
interface UseHookNameReturn {
|
||||
state: State
|
||||
actions: Actions
|
||||
}
|
||||
```
|
||||
|
||||
### Type Safety Rules
|
||||
|
||||
1. **No `any`**: Use proper types or `unknown` with type guards
|
||||
2. **Props Interface**: Always define, even for simple components
|
||||
3. **Callback Types**: Define full signature including parameters and return type
|
||||
4. **Generic Types**: Use generics for reusable components/hooks
|
||||
5. **Const Assertions**: Use `as const` for constant objects/arrays
|
||||
6. **Type Guards**: Create type guards for runtime checks
|
||||
7. **Ref Types**: Explicitly type refs (`useRef<HTMLDivElement>(null)`)
|
||||
|
||||
---
|
||||
|
||||
## Performance Patterns
|
||||
|
||||
### Memoization
|
||||
|
||||
```typescript
|
||||
// useMemo for expensive computations
|
||||
const sortedItems = useMemo(() => {
|
||||
return items.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [items])
|
||||
|
||||
// useCallback for functions passed as props
|
||||
const handleClick = useCallback((id: string) => {
|
||||
onItemClick?.(id)
|
||||
}, [onItemClick])
|
||||
|
||||
// useCallback for render functions
|
||||
const renderItem = useCallback((item: Item) => (
|
||||
<ItemComponent key={item.id} item={item} onClick={handleClick} />
|
||||
), [handleClick])
|
||||
```
|
||||
|
||||
### When to Memoize
|
||||
|
||||
1. **useMemo**: Expensive calculations, filtering/sorting large arrays, object creation in render
|
||||
2. **useCallback**: Functions passed to child components, dependencies in other hooks, event handlers used in effects
|
||||
3. **Don't Over-Memoize**: Simple calculations, primitives, or functions not passed down
|
||||
|
||||
### Refs for Avoiding Recreations
|
||||
|
||||
```typescript
|
||||
// Pattern: Use refs to avoid function recreations
|
||||
const onSuccessRef = useRef(onSuccess)
|
||||
|
||||
useEffect(() => {
|
||||
onSuccessRef.current = onSuccess
|
||||
}, [onSuccess])
|
||||
|
||||
const stableCallback = useCallback(() => {
|
||||
// Use ref so this callback never needs to change
|
||||
onSuccessRef.current?.()
|
||||
}, []) // Empty deps!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Logging and Debugging
|
||||
|
||||
### Logger Usage
|
||||
|
||||
```typescript
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('ComponentName')
|
||||
|
||||
// Use throughout component/hook
|
||||
logger.info('User action', { userId, action })
|
||||
logger.warn('Potential issue', { details })
|
||||
logger.error('Operation failed', { error })
|
||||
```
|
||||
|
||||
### Logging Rules
|
||||
|
||||
1. **No console.log**: Use logger.info/warn/error instead
|
||||
2. **Logger Per File**: Create logger with component/hook name
|
||||
3. **Structured Logging**: Pass objects with context, not just strings
|
||||
4. **Log Levels**:
|
||||
- `info`: Normal operations, user actions, state changes
|
||||
- `warn`: Unusual but handled situations, deprecations
|
||||
- `error`: Failures, exceptions, errors that need attention
|
||||
|
||||
---
|
||||
|
||||
## Linting and Formatting
|
||||
|
||||
### Automated Linting
|
||||
|
||||
**Do not manually fix linting errors.** The project uses automated linting tools that should handle formatting and style issues.
|
||||
|
||||
### Rules
|
||||
|
||||
1. **No Manual Fixes**: Do not attempt to manually reorder CSS classes, fix formatting, or address linter warnings
|
||||
2. **Use Automated Tools**: If linting errors need to be fixed, run `bun run lint` to let the automated tools handle it
|
||||
3. **Focus on Logic**: Concentrate on functionality, TypeScript correctness, and architectural patterns
|
||||
4. **Let Tools Handle Style**: Biome and other linters will automatically format code according to project standards
|
||||
|
||||
### When Linting Matters
|
||||
|
||||
- **Syntax Errors**: Fix actual syntax errors that prevent compilation
|
||||
- **Type Errors**: Address TypeScript type errors that indicate logic issues
|
||||
- **Ignore Style Warnings**: CSS class order, formatting preferences, etc. will be handled by tooling
|
||||
|
||||
```bash
|
||||
# If linting is required
|
||||
bun run lint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Quality Checklist
|
||||
|
||||
Before considering a component/hook complete, verify:
|
||||
|
||||
### Documentation
|
||||
- [ ] TSDoc in sync with implementation after any change (params/returns/behavior/throws)
|
||||
- [ ] TSDoc comment on component/hook/store
|
||||
- [ ] Props interface documented with /** */ comments
|
||||
- [ ] Complex logic explained with inline comments
|
||||
- [ ] Section comments in JSX for clarity
|
||||
|
||||
### TypeScript
|
||||
- [ ] All props have interface defined
|
||||
- [ ] No `any` types used
|
||||
- [ ] Refs properly typed
|
||||
- [ ] Return types explicit for complex hooks
|
||||
|
||||
### Performance
|
||||
- [ ] useMemo for expensive computations
|
||||
- [ ] useCallback for functions passed as props
|
||||
- [ ] Refs used to avoid unnecessary recreations
|
||||
- [ ] No unnecessary re-renders
|
||||
|
||||
### Hooks
|
||||
- [ ] Correct dependency arrays
|
||||
- [ ] Cleanup in useEffect return functions
|
||||
- [ ] Stable callback references with useCallback
|
||||
- [ ] Logic extracted to custom hooks when reusable
|
||||
|
||||
### Styling
|
||||
- [ ] No styles attributes (use className with Tailwind)
|
||||
- [ ] Dark mode variants only when values differ from light mode
|
||||
- [ ] No duplicate dark: classes with identical values
|
||||
- [ ] Consistent spacing using design tokens
|
||||
- [ ] cn() for conditional classes
|
||||
|
||||
### Accessibility
|
||||
- [ ] Semantic HTML elements
|
||||
- [ ] ARIA labels and roles where needed
|
||||
- [ ] Keyboard navigation support
|
||||
- [ ] Focus management
|
||||
|
||||
### State Management
|
||||
- [ ] Local state for UI-only concerns
|
||||
- [ ] Zustand for shared/persisted state
|
||||
- [ ] No duplicate state
|
||||
- [ ] Clear state update patterns
|
||||
|
||||
### Error Handling
|
||||
- [ ] try/catch around async operations
|
||||
- [ ] Error states tracked and displayed
|
||||
- [ ] Loading states for async actions
|
||||
- [ ] Failures logged with context
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### ❌ Don't Do This
|
||||
|
||||
```typescript
|
||||
// ❌ Inline styles
|
||||
<div style={{ width: 200, marginTop: 10 }}>
|
||||
|
||||
// ❌ Duplicate dark mode classes (same value as light mode)
|
||||
<div className='text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
<div className='bg-[var(--surface-9)] dark:bg-[var(--surface-9)]'>
|
||||
<div className='hover:bg-[var(--border)] dark:hover:bg-[var(--border)]'>
|
||||
|
||||
// ❌ console.log
|
||||
console.log('Debug info')
|
||||
|
||||
// ❌ any type
|
||||
const handleClick = (e: any) => {}
|
||||
|
||||
// ❌ Missing dependencies
|
||||
useEffect(() => {
|
||||
doSomething(prop)
|
||||
}, []) // Missing prop!
|
||||
|
||||
// ❌ Mutating state
|
||||
const handleAdd = () => {
|
||||
items.push(newItem) // Mutating!
|
||||
setItems(items)
|
||||
}
|
||||
|
||||
// ❌ No error handling
|
||||
const fetchData = async () => {
|
||||
const data = await fetch('/api/data')
|
||||
setData(data)
|
||||
}
|
||||
|
||||
// ❌ Complex logic in component
|
||||
export function Component() {
|
||||
const [data, setData] = useState([])
|
||||
useEffect(() => {
|
||||
// 50 lines of complex logic
|
||||
}, [])
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Do This Instead
|
||||
|
||||
```typescript
|
||||
// ✅ Tailwind classes
|
||||
<div className='w-[200px] mt-[10px]'>
|
||||
|
||||
// ✅ No duplicate dark classes - CSS variables already handle theming
|
||||
<div className='text-[var(--text-primary)]'>
|
||||
<div className='bg-[var(--surface-9)]'>
|
||||
<div className='hover:bg-[var(--border)]'>
|
||||
|
||||
// ✅ Only add dark: when values differ between modes
|
||||
<div className='bg-[var(--surface-6)] dark:bg-[var(--surface-9)]'>
|
||||
|
||||
// ✅ Logger
|
||||
logger.info('Debug info', { context })
|
||||
|
||||
// ✅ Proper types
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {}
|
||||
|
||||
// ✅ Complete dependencies
|
||||
useEffect(() => {
|
||||
doSomething(prop)
|
||||
}, [prop])
|
||||
|
||||
// ✅ Immutable updates
|
||||
const handleAdd = () => {
|
||||
setItems([...items, newItem])
|
||||
}
|
||||
|
||||
// ✅ Error handling
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/data')
|
||||
if (!response.ok) throw new Error('Failed to fetch')
|
||||
const data = await response.json()
|
||||
setData(data)
|
||||
} catch (error) {
|
||||
logger.error('Fetch failed', { error })
|
||||
setError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Extract to custom hook
|
||||
export function Component() {
|
||||
const { data, isLoading, error } = useFeatureData()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Examples from Codebase
|
||||
|
||||
Study these files as reference implementations:
|
||||
|
||||
### Components
|
||||
- `sidebar-new.tsx` - Main component structure, hook composition
|
||||
- `workflow-list.tsx` - Complex component with drag-drop, memoization
|
||||
- `blocks.tsx` - Simple panel component with resize
|
||||
- `triggers.tsx` - Similar panel pattern
|
||||
|
||||
### Hooks
|
||||
- `use-workspace-management.ts` - Complex hook with multiple operations, refs pattern
|
||||
- `use-sidebar-resize.ts` - Simple focused hook with event listeners
|
||||
- `use-workflow-operations.ts` - Hook coordinating store and navigation
|
||||
- `use-panel-resize.ts` - Shared resize logic pattern
|
||||
|
||||
### Stores
|
||||
- `stores/sidebar/store.ts` - Persist middleware, CSS variables, validation
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
This architecture prioritizes:
|
||||
|
||||
1. **Separation of Concerns**: Components render, hooks contain logic, stores manage state
|
||||
2. **Type Safety**: TypeScript everywhere with no escape hatches
|
||||
3. **Performance**: Smart use of memoization and refs
|
||||
4. **Maintainability**: Clear structure, documentation, and consistent patterns
|
||||
5. **Developer Experience**: Logging, error handling, and clear interfaces
|
||||
|
||||
When in doubt, follow the patterns established in the sidebar-new component family.
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
ENTERPRISE_PLAN_FEATURES,
|
||||
PRO_PLAN_FEATURES,
|
||||
TEAM_PLAN_FEATURES,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/plan-configs'
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs'
|
||||
|
||||
const logger = createLogger('LandingPricing')
|
||||
|
||||
|
||||
@@ -206,6 +206,9 @@
|
||||
--terminal-status-info-bg: #f5f5f4; /* stone-100 */
|
||||
--terminal-status-info-border: #a8a29e; /* stone-400 */
|
||||
--terminal-status-info-color: #57534e; /* stone-600 */
|
||||
--terminal-status-warning-bg: #fef9e7;
|
||||
--terminal-status-warning-border: #f5c842;
|
||||
--terminal-status-warning-color: #a16207;
|
||||
}
|
||||
.dark {
|
||||
/* Neutrals (surfaces) */
|
||||
@@ -336,6 +339,9 @@
|
||||
--terminal-status-info-bg: #383838;
|
||||
--terminal-status-info-border: #686868;
|
||||
--terminal-status-info-color: #b7b7b7;
|
||||
--terminal-status-warning-bg: #3d3520;
|
||||
--terminal-status-warning-border: #5c4d1f;
|
||||
--terminal-status-warning-color: #d4a72c;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from '@sim/db'
|
||||
import { pausedExecutions, permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm'
|
||||
import { and, desc, eq, gte, inArray, isNotNull, isNull, lte, or, type SQL, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
@@ -126,13 +126,50 @@ export async function GET(request: NextRequest) {
|
||||
// Build additional conditions for the query
|
||||
let conditions: SQL | undefined
|
||||
|
||||
// Filter by level (supports comma-separated for OR conditions)
|
||||
// Filter by level with support for derived statuses (running, pending)
|
||||
if (params.level && params.level !== 'all') {
|
||||
const levels = params.level.split(',').filter(Boolean)
|
||||
if (levels.length === 1) {
|
||||
conditions = and(conditions, eq(workflowExecutionLogs.level, levels[0]))
|
||||
} else if (levels.length > 1) {
|
||||
conditions = and(conditions, inArray(workflowExecutionLogs.level, levels))
|
||||
const levelConditions: SQL[] = []
|
||||
|
||||
for (const level of levels) {
|
||||
if (level === 'error') {
|
||||
// Direct database field
|
||||
levelConditions.push(eq(workflowExecutionLogs.level, 'error'))
|
||||
} else if (level === 'info') {
|
||||
// Completed info logs only (not running, not pending)
|
||||
const condition = and(
|
||||
eq(workflowExecutionLogs.level, 'info'),
|
||||
isNotNull(workflowExecutionLogs.endedAt)
|
||||
)
|
||||
if (condition) levelConditions.push(condition)
|
||||
} else if (level === 'running') {
|
||||
// Running logs: info level with no endedAt
|
||||
const condition = and(
|
||||
eq(workflowExecutionLogs.level, 'info'),
|
||||
isNull(workflowExecutionLogs.endedAt)
|
||||
)
|
||||
if (condition) levelConditions.push(condition)
|
||||
} else if (level === 'pending') {
|
||||
// Pending logs: info level with pause status indicators
|
||||
const condition = and(
|
||||
eq(workflowExecutionLogs.level, 'info'),
|
||||
or(
|
||||
sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`,
|
||||
and(
|
||||
isNotNull(pausedExecutions.status),
|
||||
sql`${pausedExecutions.status} != 'fully_resumed'`
|
||||
)
|
||||
)
|
||||
)
|
||||
if (condition) levelConditions.push(condition)
|
||||
}
|
||||
}
|
||||
|
||||
if (levelConditions.length > 0) {
|
||||
conditions = and(
|
||||
conditions,
|
||||
levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { and, eq, gte, inArray, lte } from 'drizzle-orm'
|
||||
import { pausedExecutions, permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { and, eq, gte, inArray, isNotNull, isNull, lte, or, type SQL, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
@@ -15,6 +15,7 @@ const QueryParamsSchema = z.object({
|
||||
workflowIds: z.string().optional(),
|
||||
folderIds: z.string().optional(),
|
||||
triggers: z.string().optional(),
|
||||
level: z.string().optional(), // Supports comma-separated values: 'error,running'
|
||||
})
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
@@ -84,20 +85,73 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
inArray(workflowExecutionLogs.workflowId, workflowIdList),
|
||||
gte(workflowExecutionLogs.startedAt, start),
|
||||
lte(workflowExecutionLogs.startedAt, end),
|
||||
] as any[]
|
||||
] as SQL[]
|
||||
if (qp.triggers) {
|
||||
const t = qp.triggers.split(',').filter(Boolean)
|
||||
logWhere.push(inArray(workflowExecutionLogs.trigger, t))
|
||||
}
|
||||
|
||||
// Handle level filtering with support for derived statuses and multiple selections
|
||||
if (qp.level && qp.level !== 'all') {
|
||||
const levels = qp.level.split(',').filter(Boolean)
|
||||
const levelConditions: SQL[] = []
|
||||
|
||||
for (const level of levels) {
|
||||
if (level === 'error') {
|
||||
levelConditions.push(eq(workflowExecutionLogs.level, 'error'))
|
||||
} else if (level === 'info') {
|
||||
// Completed info logs only
|
||||
const condition = and(
|
||||
eq(workflowExecutionLogs.level, 'info'),
|
||||
isNotNull(workflowExecutionLogs.endedAt)
|
||||
)
|
||||
if (condition) levelConditions.push(condition)
|
||||
} else if (level === 'running') {
|
||||
// Running logs: info level with no endedAt
|
||||
const condition = and(
|
||||
eq(workflowExecutionLogs.level, 'info'),
|
||||
isNull(workflowExecutionLogs.endedAt)
|
||||
)
|
||||
if (condition) levelConditions.push(condition)
|
||||
} else if (level === 'pending') {
|
||||
// Pending logs: info level with pause status indicators
|
||||
const condition = and(
|
||||
eq(workflowExecutionLogs.level, 'info'),
|
||||
or(
|
||||
sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`,
|
||||
and(
|
||||
isNotNull(pausedExecutions.status),
|
||||
sql`${pausedExecutions.status} != 'fully_resumed'`
|
||||
)
|
||||
)
|
||||
)
|
||||
if (condition) levelConditions.push(condition)
|
||||
}
|
||||
}
|
||||
|
||||
if (levelConditions.length > 0) {
|
||||
const combinedCondition =
|
||||
levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions)
|
||||
if (combinedCondition) logWhere.push(combinedCondition)
|
||||
}
|
||||
}
|
||||
|
||||
const logs = await db
|
||||
.select({
|
||||
workflowId: workflowExecutionLogs.workflowId,
|
||||
level: workflowExecutionLogs.level,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
pausedTotalPauseCount: pausedExecutions.totalPauseCount,
|
||||
pausedResumedCount: pausedExecutions.resumedCount,
|
||||
pausedStatus: pausedExecutions.status,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.leftJoin(
|
||||
pausedExecutions,
|
||||
eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)
|
||||
)
|
||||
.where(and(...logWhere))
|
||||
|
||||
type Bucket = {
|
||||
|
||||
@@ -4,11 +4,41 @@ import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { client, useSession } from '@/lib/auth/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getErrorMessage } from '@/app/invite/[id]/utils'
|
||||
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
|
||||
|
||||
const logger = createLogger('InviteById')
|
||||
|
||||
function getErrorMessage(reason: string): string {
|
||||
switch (reason) {
|
||||
case 'missing-token':
|
||||
return 'The invitation link is invalid or missing a required parameter.'
|
||||
case 'invalid-token':
|
||||
return 'The invitation link is invalid or has already been used.'
|
||||
case 'expired':
|
||||
return 'This invitation has expired. Please ask for a new invitation.'
|
||||
case 'already-processed':
|
||||
return 'This invitation has already been accepted or declined.'
|
||||
case 'email-mismatch':
|
||||
return 'This invitation was sent to a different email address. Please log in with the correct account.'
|
||||
case 'workspace-not-found':
|
||||
return 'The workspace associated with this invitation could not be found.'
|
||||
case 'user-not-found':
|
||||
return 'Your user account could not be found. Please try logging out and logging back in.'
|
||||
case 'already-member':
|
||||
return 'You are already a member of this organization or workspace.'
|
||||
case 'already-in-organization':
|
||||
return 'You are already a member of an organization. Leave your current organization before accepting a new invitation.'
|
||||
case 'invalid-invitation':
|
||||
return 'This invitation is invalid or no longer exists.'
|
||||
case 'missing-invitation-id':
|
||||
return 'The invitation link is missing required information. Please use the original invitation link.'
|
||||
case 'server-error':
|
||||
return 'An unexpected error occurred while processing your invitation. Please try again later.'
|
||||
default:
|
||||
return 'An unknown error occurred while processing your invitation.'
|
||||
}
|
||||
}
|
||||
|
||||
export default function Invite() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
export function getErrorMessage(reason: string): string {
|
||||
switch (reason) {
|
||||
case 'missing-token':
|
||||
return 'The invitation link is invalid or missing a required parameter.'
|
||||
case 'invalid-token':
|
||||
return 'The invitation link is invalid or has already been used.'
|
||||
case 'expired':
|
||||
return 'This invitation has expired. Please ask for a new invitation.'
|
||||
case 'already-processed':
|
||||
return 'This invitation has already been accepted or declined.'
|
||||
case 'email-mismatch':
|
||||
return 'This invitation was sent to a different email address. Please log in with the correct account.'
|
||||
case 'workspace-not-found':
|
||||
return 'The workspace associated with this invitation could not be found.'
|
||||
case 'user-not-found':
|
||||
return 'Your user account could not be found. Please try logging out and logging back in.'
|
||||
case 'already-member':
|
||||
return 'You are already a member of this organization or workspace.'
|
||||
case 'already-in-organization':
|
||||
return 'You are already a member of an organization. Leave your current organization before accepting a new invitation.'
|
||||
case 'invalid-invitation':
|
||||
return 'This invitation is invalid or no longer exists.'
|
||||
case 'missing-invitation-id':
|
||||
return 'The invitation link is missing required information. Please use the original invitation link.'
|
||||
case 'server-error':
|
||||
return 'An unexpected error occurred while processing your invitation. Please try again later.'
|
||||
default:
|
||||
return 'An unknown error occurred while processing your invitation.'
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChartNoAxesColumn,
|
||||
ChevronDown,
|
||||
Globe,
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Copy,
|
||||
Popover,
|
||||
@@ -267,13 +267,13 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (isWorkspaceContext) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push('/templates')
|
||||
}
|
||||
}
|
||||
const breadcrumbItems = [
|
||||
{
|
||||
label: 'Templates',
|
||||
href: isWorkspaceContext ? `/workspace/${workspaceId}/templates` : '/templates',
|
||||
},
|
||||
{ label: template?.name || 'Template' },
|
||||
]
|
||||
/**
|
||||
* Intercepts wheel events over the workflow preview so that the page handles scrolling
|
||||
* instead of the underlying canvas. We stop propagation in the capture phase to prevent
|
||||
@@ -542,24 +542,15 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-screen flex-col', isWorkspaceContext && 'pl-64')}>
|
||||
<div className={cn('flex flex-col', isWorkspaceContext ? 'h-full flex-1' : 'min-h-screen')}>
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
|
||||
{/* Top bar with back button */}
|
||||
<div className='flex items-center justify-between'>
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className='flex items-center gap-[6px] font-medium text-[#ADADAD] text-[14px] transition-colors hover:text-white'
|
||||
>
|
||||
<ArrowLeft className='h-[14px] w-[14px]' />
|
||||
<span>More Templates</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* Breadcrumb navigation */}
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
|
||||
{/* Template name and action buttons */}
|
||||
<div className='mt-[24px] flex items-center justify-between'>
|
||||
<h1 className='font-medium text-[18px]'>{template.name}</h1>
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<h1 className='font-medium text-[18px] text-[var(--text-primary)]'>{template.name}</h1>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
@@ -706,7 +697,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
|
||||
{/* Template tagline */}
|
||||
{template.details?.tagline && (
|
||||
<p className='mt-[4px] font-medium text-[#888888] text-[14px]'>
|
||||
<p className='mt-[4px] font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
{template.details.tagline}
|
||||
</p>
|
||||
)}
|
||||
@@ -718,18 +709,22 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
onClick={handleStarToggle}
|
||||
className={cn(
|
||||
'h-[14px] w-[14px] cursor-pointer transition-colors',
|
||||
template.isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
|
||||
template.isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[var(--text-muted)]',
|
||||
starTemplate.isPending && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
<span className='font-medium text-[#888888] text-[14px]'>{template.stars || 0}</span>
|
||||
<span className='font-medium text-[14px] text-[var(--text-muted)]'>
|
||||
{template.stars || 0}
|
||||
</span>
|
||||
|
||||
{/* Users icon and count */}
|
||||
<ChartNoAxesColumn className='h-[16px] w-[16px] text-[#888888]' />
|
||||
<span className='font-medium text-[#888888] text-[14px]'>{template.views}</span>
|
||||
<ChartNoAxesColumn className='h-[16px] w-[16px] text-[var(--text-muted)]' />
|
||||
<span className='font-medium text-[14px] text-[var(--text-muted)]'>
|
||||
{template.views}
|
||||
</span>
|
||||
|
||||
{/* Vertical divider */}
|
||||
<div className='mx-[4px] mb-[-1.5px] h-[18px] w-[1.25px] rounded-full bg-[#3A3A3A]' />
|
||||
<div className='mx-[4px] mb-[-1.5px] h-[18px] w-[1.25px] rounded-full bg-[var(--border)]' />
|
||||
|
||||
{/* Creator profile pic */}
|
||||
{template.creator?.profileImageUrl ? (
|
||||
@@ -741,13 +736,13 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-full bg-[#4A4A4A]'>
|
||||
<User className='h-[14px] w-[14px] text-[#888888]' />
|
||||
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-full bg-[var(--surface-elevated)]'>
|
||||
<User className='h-[14px] w-[14px] text-[var(--text-muted)]' />
|
||||
</div>
|
||||
)}
|
||||
{/* Creator name */}
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<span className='font-medium text-[#8B8B8B] text-[14px]'>
|
||||
<span className='font-medium text-[14px] text-[var(--text-muted)]'>
|
||||
{template.creator?.name || 'Unknown'}
|
||||
</span>
|
||||
{template.creator?.verified && <VerifiedBadge size='md' />}
|
||||
@@ -757,7 +752,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
{/* Credentials needed */}
|
||||
{Array.isArray(template.requiredCredentials) &&
|
||||
template.requiredCredentials.length > 0 && (
|
||||
<p className='mt-[12px] font-medium text-[#888888] text-[12px]'>
|
||||
<p className='mt-[12px] font-medium text-[12px] text-[var(--text-muted)]'>
|
||||
Credentials needed:{' '}
|
||||
{template.requiredCredentials
|
||||
.map((cred: CredentialRequirement) => {
|
||||
@@ -783,7 +778,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
{/* Last updated overlay */}
|
||||
{template.updatedAt && (
|
||||
<div className='pointer-events-none absolute right-[12px] bottom-[12px] rounded-[4px] bg-[var(--bg)]/80 px-[8px] py-[4px] backdrop-blur-sm'>
|
||||
<span className='font-medium text-[#8B8B8B] text-[12px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-muted)]'>
|
||||
Last updated{' '}
|
||||
{formatDistanceToNow(new Date(template.updatedAt), {
|
||||
addSuffix: true,
|
||||
@@ -910,8 +905,8 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex h-[48px] w-[48px] flex-shrink-0 items-center justify-center rounded-full bg-[#4A4A4A]'>
|
||||
<User className='h-[24px] w-[24px] text-[#888888]' />
|
||||
<div className='flex h-[48px] w-[48px] flex-shrink-0 items-center justify-center rounded-full bg-[var(--surface-elevated)]'>
|
||||
<User className='h-[24px] w-[24px] text-[var(--text-muted)]' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -932,7 +927,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
href={template.creator.details.websiteUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center text-[#888888] transition-colors hover:text-[var(--text-primary)]'
|
||||
className='flex items-center text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
|
||||
aria-label='Website'
|
||||
>
|
||||
<Globe className='h-[14px] w-[14px]' />
|
||||
@@ -943,7 +938,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
href={template.creator.details.xUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center text-[#888888] transition-colors hover:text-[var(--text-primary)]'
|
||||
className='flex items-center text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
|
||||
aria-label='X (Twitter)'
|
||||
>
|
||||
<svg
|
||||
@@ -960,7 +955,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
href={template.creator.details.linkedinUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center text-[#888888] transition-colors hover:text-[var(--text-primary)]'
|
||||
className='flex items-center text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
|
||||
aria-label='LinkedIn'
|
||||
>
|
||||
<Linkedin className='h-[14px] w-[14px]' />
|
||||
@@ -969,7 +964,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
{template.creator.details?.contactEmail && (
|
||||
<a
|
||||
href={`mailto:${template.creator.details.contactEmail}`}
|
||||
className='flex items-center text-[#888888] transition-colors hover:text-[var(--text-primary)]'
|
||||
className='flex items-center text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
|
||||
aria-label='Email'
|
||||
>
|
||||
<Mail className='h-[14px] w-[14px]' />
|
||||
|
||||
@@ -199,7 +199,7 @@ function TemplateCardInner({
|
||||
>
|
||||
<div
|
||||
ref={previewRef}
|
||||
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
|
||||
className='pointer-events-none h-[180px] w-full cursor-pointer overflow-hidden rounded-[6px]'
|
||||
>
|
||||
{normalizedState && isInView ? (
|
||||
<WorkflowPreview
|
||||
|
||||
@@ -135,15 +135,15 @@ export default function Templates({
|
||||
return (
|
||||
<div className='flex h-[100vh] flex-col'>
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
|
||||
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[28px] pb-[24px]'>
|
||||
<div>
|
||||
<div className='flex items-start gap-[12px]'>
|
||||
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#7A5F11] bg-[#514215]'>
|
||||
<Layout className='h-[14px] w-[14px] text-[#FBBC04]' />
|
||||
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#1E3A5A] bg-[#0F2A3D]'>
|
||||
<Layout className='h-[14px] w-[14px] text-[#60A5FA]' />
|
||||
</div>
|
||||
<h1 className='font-medium text-[18px]'>Templates</h1>
|
||||
</div>
|
||||
<p className='mt-[10px] font-base text-[#888888] text-[14px]'>
|
||||
<p className='mt-[10px] text-[14px] text-[var(--text-tertiary)]'>
|
||||
Grab a template and start building, or make one from scratch.
|
||||
</p>
|
||||
</div>
|
||||
@@ -178,15 +178,13 @@ export default function Templates({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-[24px] h-[1px] w-full border-[var(--border)] border-t' />
|
||||
|
||||
<div className='mt-[24px] grid grid-cols-1 gap-x-[20px] gap-y-[40px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading ? (
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<TemplateCardSkeleton key={`skeleton-${index}`} />
|
||||
))
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 border-dashed bg-muted/20'>
|
||||
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
|
||||
<div className='text-center'>
|
||||
<p className='font-medium text-muted-foreground text-sm'>{emptyState.title}</p>
|
||||
<p className='mt-1 text-muted-foreground/70 text-xs'>{emptyState.description}</p>
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { Button, Textarea } from '@/components/emcn'
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@/components/emcn/components/modal/modal'
|
||||
import { Label } from '@/components/ui/label'
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { ChunkData, DocumentData } from '@/stores/knowledge/store'
|
||||
|
||||
@@ -119,24 +120,12 @@ export function CreateChunkModal({
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={handleCloseAttempt}>
|
||||
<ModalContent className='h-[74vh] sm:max-w-[600px]'>
|
||||
<ModalContent size='lg'>
|
||||
<ModalHeader>Create Chunk</ModalHeader>
|
||||
|
||||
<form className='flex min-h-0 flex-1 flex-col'>
|
||||
<ModalBody>
|
||||
<div className='space-y-[12px]'>
|
||||
{/* Document Info Section */}
|
||||
<div className='flex items-center gap-3 rounded-lg border p-4'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='font-medium text-[var(--text-primary)] text-sm'>
|
||||
{document?.filename || 'Unknown Document'}
|
||||
</p>
|
||||
<p className='text-[var(--text-tertiary)] text-xs'>
|
||||
Adding chunk to this document
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form>
|
||||
<ModalBody className='!pb-[16px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className='flex items-center gap-2 rounded-md border border-[var(--text-error)]/50 bg-[var(--text-error)]/10 p-3'>
|
||||
@@ -146,22 +135,15 @@ export function CreateChunkModal({
|
||||
)}
|
||||
|
||||
{/* Content Input Section */}
|
||||
<div className='space-y-[8px]'>
|
||||
<Label
|
||||
htmlFor='content'
|
||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
||||
>
|
||||
Chunk Content
|
||||
</Label>
|
||||
<Textarea
|
||||
id='content'
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder='Enter the content for this chunk...'
|
||||
rows={10}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
<Label htmlFor='content'>Chunk</Label>
|
||||
<Textarea
|
||||
id='content'
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder='Enter the content for this chunk...'
|
||||
rows={12}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
@@ -196,7 +178,7 @@ export function CreateChunkModal({
|
||||
|
||||
{/* Unsaved Changes Alert */}
|
||||
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Discard Changes</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { ChunkData } from '@/stores/knowledge/store'
|
||||
|
||||
@@ -68,7 +66,7 @@ export function DeleteChunkModal({
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={onClose}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Chunk</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
@@ -86,17 +84,7 @@ export function DeleteChunkModal({
|
||||
disabled={isDeleting}
|
||||
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash className='mr-2 h-4 w-4' />
|
||||
Delete
|
||||
</>
|
||||
)}
|
||||
{isDeleting ? <>Deleting...</> : <>Delete</>}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import {
|
||||
ChunkTableSkeleton,
|
||||
KnowledgeHeader,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
|
||||
interface DocumentLoadingProps {
|
||||
knowledgeBaseId: string
|
||||
knowledgeBaseName: string
|
||||
documentName: string
|
||||
}
|
||||
|
||||
export function DocumentLoading({
|
||||
knowledgeBaseId,
|
||||
knowledgeBaseName,
|
||||
documentName,
|
||||
}: DocumentLoadingProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params?.workspaceId as string
|
||||
|
||||
const breadcrumbs = [
|
||||
{
|
||||
id: 'knowledge-root',
|
||||
label: 'Knowledge',
|
||||
href: `/workspace/${workspaceId}/knowledge`,
|
||||
},
|
||||
{
|
||||
id: `knowledge-base-${knowledgeBaseId}`,
|
||||
label: knowledgeBaseName,
|
||||
href: `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`,
|
||||
},
|
||||
{
|
||||
id: `document-${knowledgeBaseId}-${documentName}`,
|
||||
label: documentName,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className='flex h-[100vh] flex-col pl-64'>
|
||||
{/* Header with Breadcrumbs */}
|
||||
<KnowledgeHeader breadcrumbs={breadcrumbs} />
|
||||
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
||||
{/* Main Content */}
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<div className='px-6 pb-6'>
|
||||
{/* Search Section */}
|
||||
<div className='mb-4 flex items-center justify-between pt-1'>
|
||||
<div className='relative max-w-md'>
|
||||
<div className='relative flex items-center'>
|
||||
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-[18px] w-[18px] transform text-muted-foreground' />
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Search chunks...'
|
||||
disabled
|
||||
className='h-10 w-full rounded-md border bg-background px-9 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button disabled variant='primary' className='flex items-center gap-1'>
|
||||
<div className='h-3.5 w-3.5 animate-pulse rounded bg-primary-foreground/30' />
|
||||
<span>Create Chunk</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table container */}
|
||||
<ChunkTableSkeleton isSidebarCollapsed={false} rowCount={8} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Trash,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { MAX_TAG_SLOTS, TAG_SLOTS, type TagSlot } from '@/lib/knowledge/constants'
|
||||
import type { DocumentTag } from '@/lib/knowledge/tags/types'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type TagDefinition,
|
||||
useKnowledgeBaseTagDefinitions,
|
||||
} from '@/hooks/use-knowledge-base-tag-definitions'
|
||||
import { useNextAvailableSlot } from '@/hooks/use-next-available-slot'
|
||||
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/use-tag-definitions'
|
||||
import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
|
||||
|
||||
const logger = createLogger('DocumentTagsModal')
|
||||
|
||||
interface DocumentTagsModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
knowledgeBaseId: string
|
||||
documentId: string
|
||||
documentData: DocumentData | null
|
||||
onDocumentUpdate?: (updates: Record<string, string>) => void
|
||||
}
|
||||
|
||||
export function DocumentTagsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
knowledgeBaseId,
|
||||
documentId,
|
||||
documentData,
|
||||
onDocumentUpdate,
|
||||
}: DocumentTagsModalProps) {
|
||||
const { updateDocument: updateDocumentInStore } = useKnowledgeStore()
|
||||
|
||||
const documentTagHook = useTagDefinitions(knowledgeBaseId, documentId)
|
||||
const kbTagHook = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
const { getNextAvailableSlot: getServerNextSlot } = useNextAvailableSlot(knowledgeBaseId)
|
||||
|
||||
const { saveTagDefinitions, tagDefinitions, fetchTagDefinitions } = documentTagHook
|
||||
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook
|
||||
|
||||
const [documentTags, setDocumentTags] = useState<DocumentTag[]>([])
|
||||
const [editingTagIndex, setEditingTagIndex] = useState<number | null>(null)
|
||||
const [isCreatingTag, setIsCreatingTag] = useState(false)
|
||||
const [isSavingTag, setIsSavingTag] = useState(false)
|
||||
const [editTagForm, setEditTagForm] = useState({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
value: '',
|
||||
})
|
||||
|
||||
const buildDocumentTags = useCallback((docData: DocumentData, definitions: TagDefinition[]) => {
|
||||
const tags: DocumentTag[] = []
|
||||
|
||||
TAG_SLOTS.forEach((slot) => {
|
||||
const value = docData[slot] as string | null | undefined
|
||||
const definition = definitions.find((def) => def.tagSlot === slot)
|
||||
|
||||
if (value?.trim() && definition) {
|
||||
tags.push({
|
||||
slot,
|
||||
displayName: definition.displayName,
|
||||
fieldType: definition.fieldType,
|
||||
value: value.trim(),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return tags
|
||||
}, [])
|
||||
|
||||
const handleTagsChange = useCallback((newTags: DocumentTag[]) => {
|
||||
setDocumentTags(newTags)
|
||||
}, [])
|
||||
|
||||
const handleSaveDocumentTags = useCallback(
|
||||
async (tagsToSave: DocumentTag[]) => {
|
||||
if (!documentData) return
|
||||
|
||||
try {
|
||||
const tagData: Record<string, string> = {}
|
||||
|
||||
TAG_SLOTS.forEach((slot) => {
|
||||
tagData[slot] = ''
|
||||
})
|
||||
|
||||
tagsToSave.forEach((tag) => {
|
||||
if (tag.value.trim()) {
|
||||
tagData[tag.slot] = tag.value.trim()
|
||||
}
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(tagData),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update document tags')
|
||||
}
|
||||
|
||||
updateDocumentInStore(knowledgeBaseId, documentId, tagData)
|
||||
onDocumentUpdate?.(tagData)
|
||||
|
||||
await fetchTagDefinitions()
|
||||
} catch (error) {
|
||||
logger.error('Error updating document tags:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[
|
||||
documentData,
|
||||
knowledgeBaseId,
|
||||
documentId,
|
||||
updateDocumentInStore,
|
||||
fetchTagDefinitions,
|
||||
onDocumentUpdate,
|
||||
]
|
||||
)
|
||||
|
||||
const handleRemoveTag = async (index: number) => {
|
||||
const updatedTags = documentTags.filter((_, i) => i !== index)
|
||||
handleTagsChange(updatedTags)
|
||||
|
||||
try {
|
||||
await handleSaveDocumentTags(updatedTags)
|
||||
} catch (error) {
|
||||
logger.error('Error removing tag:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const startEditingTag = (index: number) => {
|
||||
const tag = documentTags[index]
|
||||
setEditingTagIndex(index)
|
||||
setEditTagForm({
|
||||
displayName: tag.displayName,
|
||||
fieldType: tag.fieldType,
|
||||
value: tag.value,
|
||||
})
|
||||
setIsCreatingTag(false)
|
||||
}
|
||||
|
||||
const openTagCreator = () => {
|
||||
setEditingTagIndex(null)
|
||||
setEditTagForm({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
value: '',
|
||||
})
|
||||
setIsCreatingTag(true)
|
||||
}
|
||||
|
||||
const cancelEditingTag = () => {
|
||||
setEditTagForm({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
value: '',
|
||||
})
|
||||
setEditingTagIndex(null)
|
||||
setIsCreatingTag(false)
|
||||
}
|
||||
|
||||
const hasTagNameConflict = (name: string) => {
|
||||
if (!name.trim()) return false
|
||||
|
||||
return documentTags.some((tag, index) => {
|
||||
if (editingTagIndex !== null && index === editingTagIndex) {
|
||||
return false
|
||||
}
|
||||
return tag.displayName.toLowerCase() === name.trim().toLowerCase()
|
||||
})
|
||||
}
|
||||
|
||||
const availableDefinitions = kbTagDefinitions.filter((def) => {
|
||||
return !documentTags.some(
|
||||
(tag) => tag.displayName.toLowerCase() === def.displayName.toLowerCase()
|
||||
)
|
||||
})
|
||||
|
||||
const tagNameOptions = availableDefinitions.map((def) => ({
|
||||
label: def.displayName,
|
||||
value: def.displayName,
|
||||
}))
|
||||
|
||||
const saveDocumentTag = async () => {
|
||||
if (!editTagForm.displayName.trim() || !editTagForm.value.trim()) return
|
||||
|
||||
const formData = { ...editTagForm }
|
||||
const currentEditingIndex = editingTagIndex
|
||||
const originalTag = currentEditingIndex !== null ? documentTags[currentEditingIndex] : null
|
||||
setEditingTagIndex(null)
|
||||
setIsCreatingTag(false)
|
||||
setIsSavingTag(true)
|
||||
|
||||
try {
|
||||
let targetSlot: string
|
||||
|
||||
if (currentEditingIndex !== null && originalTag) {
|
||||
targetSlot = originalTag.slot
|
||||
} else {
|
||||
const existingDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === formData.displayName.toLowerCase()
|
||||
)
|
||||
|
||||
if (existingDefinition) {
|
||||
targetSlot = existingDefinition.tagSlot
|
||||
} else {
|
||||
const serverSlot = await getServerNextSlot(formData.fieldType)
|
||||
if (!serverSlot) {
|
||||
throw new Error(`No available slots for new tag of type '${formData.fieldType}'`)
|
||||
}
|
||||
targetSlot = serverSlot
|
||||
}
|
||||
}
|
||||
|
||||
let updatedTags: DocumentTag[]
|
||||
if (currentEditingIndex !== null) {
|
||||
updatedTags = [...documentTags]
|
||||
updatedTags[currentEditingIndex] = {
|
||||
...updatedTags[currentEditingIndex],
|
||||
displayName: formData.displayName,
|
||||
fieldType: formData.fieldType,
|
||||
value: formData.value,
|
||||
}
|
||||
} else {
|
||||
const newTag: DocumentTag = {
|
||||
slot: targetSlot,
|
||||
displayName: formData.displayName,
|
||||
fieldType: formData.fieldType,
|
||||
value: formData.value,
|
||||
}
|
||||
updatedTags = [...documentTags, newTag]
|
||||
}
|
||||
|
||||
handleTagsChange(updatedTags)
|
||||
|
||||
if (currentEditingIndex !== null && originalTag) {
|
||||
const currentDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === originalTag.displayName.toLowerCase()
|
||||
)
|
||||
|
||||
if (currentDefinition) {
|
||||
const updatedDefinition: TagDefinitionInput = {
|
||||
displayName: formData.displayName,
|
||||
fieldType: currentDefinition.fieldType,
|
||||
tagSlot: currentDefinition.tagSlot,
|
||||
_originalDisplayName: originalTag.displayName,
|
||||
}
|
||||
|
||||
if (saveTagDefinitions) {
|
||||
await saveTagDefinitions([updatedDefinition])
|
||||
}
|
||||
await refreshTagDefinitions()
|
||||
}
|
||||
} else {
|
||||
const existingDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === formData.displayName.toLowerCase()
|
||||
)
|
||||
|
||||
if (!existingDefinition) {
|
||||
const newDefinition: TagDefinitionInput = {
|
||||
displayName: formData.displayName,
|
||||
fieldType: formData.fieldType,
|
||||
tagSlot: targetSlot as TagSlot,
|
||||
}
|
||||
|
||||
if (saveTagDefinitions) {
|
||||
await saveTagDefinitions([newDefinition])
|
||||
}
|
||||
await refreshTagDefinitions()
|
||||
}
|
||||
}
|
||||
|
||||
await handleSaveDocumentTags(updatedTags)
|
||||
|
||||
setEditTagForm({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
value: '',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error saving tag:', error)
|
||||
} finally {
|
||||
setIsSavingTag(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isTagEditing = editingTagIndex !== null || isCreatingTag
|
||||
const tagNameConflict = hasTagNameConflict(editTagForm.displayName)
|
||||
|
||||
const hasTagChanges = () => {
|
||||
if (editingTagIndex === null) return true
|
||||
|
||||
const originalTag = documentTags[editingTagIndex]
|
||||
if (!originalTag) return true
|
||||
|
||||
return (
|
||||
originalTag.displayName !== editTagForm.displayName ||
|
||||
originalTag.value !== editTagForm.value ||
|
||||
originalTag.fieldType !== editTagForm.fieldType
|
||||
)
|
||||
}
|
||||
|
||||
const canSaveTag =
|
||||
editTagForm.displayName.trim() &&
|
||||
editTagForm.value.trim() &&
|
||||
!tagNameConflict &&
|
||||
hasTagChanges()
|
||||
|
||||
const canAddNewTag = kbTagDefinitions.length < MAX_TAG_SLOTS || availableDefinitions.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (documentData && tagDefinitions && !isSavingTag) {
|
||||
const rebuiltTags = buildDocumentTags(documentData, tagDefinitions)
|
||||
setDocumentTags(rebuiltTags)
|
||||
}
|
||||
}, [documentData, tagDefinitions, buildDocumentTags, isSavingTag])
|
||||
|
||||
const handleClose = (openState: boolean) => {
|
||||
if (!openState) {
|
||||
setIsCreatingTag(false)
|
||||
setEditingTagIndex(null)
|
||||
setEditTagForm({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
value: '',
|
||||
})
|
||||
}
|
||||
onOpenChange(openState)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span>Document Tags</span>
|
||||
</div>
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody className='!pb-[16px]'>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='space-y-[8px]'>
|
||||
<Label>
|
||||
Tags{' '}
|
||||
<span className='pl-[6px] text-[var(--text-tertiary)]'>
|
||||
{documentTags.length}/{MAX_TAG_SLOTS} slots used
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
{documentTags.length === 0 && !isCreatingTag && (
|
||||
<div className='rounded-[6px] border p-[16px] text-center'>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
No tags added yet. Add tags to help organize this document.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{documentTags.map((tag, index) => (
|
||||
<div key={index} className='space-y-[8px]'>
|
||||
<div
|
||||
className='flex cursor-pointer items-center gap-2 rounded-[4px] border p-[8px] hover:bg-[var(--surface-2)]'
|
||||
onClick={() => startEditingTag(index)}
|
||||
>
|
||||
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
|
||||
{tag.displayName}
|
||||
</span>
|
||||
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
|
||||
<span className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-muted)]'>
|
||||
{tag.value}
|
||||
</span>
|
||||
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemoveTag(index)
|
||||
}}
|
||||
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingTagIndex === index && (
|
||||
<div className='space-y-[8px] rounded-[6px] border p-[12px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor={`tagName-${index}`}>Tag Name</Label>
|
||||
{availableDefinitions.length > 0 ? (
|
||||
<Combobox
|
||||
id={`tagName-${index}`}
|
||||
options={tagNameOptions}
|
||||
value={editTagForm.displayName}
|
||||
selectedValue={editTagForm.displayName}
|
||||
onChange={(value) => {
|
||||
const def = kbTagDefinitions.find(
|
||||
(d) => d.displayName.toLowerCase() === value.toLowerCase()
|
||||
)
|
||||
setEditTagForm({
|
||||
...editTagForm,
|
||||
displayName: value,
|
||||
fieldType: def?.fieldType || 'text',
|
||||
})
|
||||
}}
|
||||
placeholder='Enter or select tag name'
|
||||
editable={true}
|
||||
className={cn(tagNameConflict && 'border-[var(--text-error)]')}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={`tagName-${index}`}
|
||||
value={editTagForm.displayName}
|
||||
onChange={(e) =>
|
||||
setEditTagForm({ ...editTagForm, displayName: e.target.value })
|
||||
}
|
||||
placeholder='Enter tag name'
|
||||
className={cn(tagNameConflict && 'border-[var(--text-error)]')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSaveTag) {
|
||||
e.preventDefault()
|
||||
saveDocumentTag()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditingTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tagNameConflict && (
|
||||
<span className='text-[11px] text-[var(--text-error)]'>
|
||||
A tag with this name already exists
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type selector commented out - only "text" type is currently supported
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor={`tagType-${index}`}>Type</Label>
|
||||
<Input id={`tagType-${index}`} value='Text' disabled className='capitalize' />
|
||||
</div>
|
||||
*/}
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor={`tagValue-${index}`}>Value</Label>
|
||||
<Input
|
||||
id={`tagValue-${index}`}
|
||||
value={editTagForm.value}
|
||||
onChange={(e) =>
|
||||
setEditTagForm({ ...editTagForm, value: e.target.value })
|
||||
}
|
||||
placeholder='Enter tag value'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSaveTag) {
|
||||
e.preventDefault()
|
||||
saveDocumentTag()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditingTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-[8px]'>
|
||||
<Button variant='default' onClick={cancelEditingTag} className='flex-1'>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={saveDocumentTag}
|
||||
className='flex-1'
|
||||
disabled={!canSaveTag}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!isTagEditing && (
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={openTagCreator}
|
||||
disabled={!canAddNewTag}
|
||||
className='w-full'
|
||||
>
|
||||
Add Tag
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCreatingTag && (
|
||||
<div className='space-y-[8px] rounded-[6px] border p-[12px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='newTagName'>Tag Name</Label>
|
||||
{tagNameOptions.length > 0 ? (
|
||||
<Combobox
|
||||
id='newTagName'
|
||||
options={tagNameOptions}
|
||||
value={editTagForm.displayName}
|
||||
selectedValue={editTagForm.displayName}
|
||||
onChange={(value) => {
|
||||
const def = kbTagDefinitions.find(
|
||||
(d) => d.displayName.toLowerCase() === value.toLowerCase()
|
||||
)
|
||||
setEditTagForm({
|
||||
...editTagForm,
|
||||
displayName: value,
|
||||
fieldType: def?.fieldType || 'text',
|
||||
})
|
||||
}}
|
||||
placeholder='Enter or select tag name'
|
||||
editable={true}
|
||||
className={cn(tagNameConflict && 'border-[var(--text-error)]')}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id='newTagName'
|
||||
value={editTagForm.displayName}
|
||||
onChange={(e) =>
|
||||
setEditTagForm({ ...editTagForm, displayName: e.target.value })
|
||||
}
|
||||
placeholder='Enter tag name'
|
||||
className={cn(tagNameConflict && 'border-[var(--text-error)]')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSaveTag) {
|
||||
e.preventDefault()
|
||||
saveDocumentTag()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditingTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tagNameConflict && (
|
||||
<span className='text-[11px] text-[var(--text-error)]'>
|
||||
A tag with this name already exists
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type selector commented out - only "text" type is currently supported
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='newTagType'>Type</Label>
|
||||
<Input id='newTagType' value='Text' disabled className='capitalize' />
|
||||
</div>
|
||||
*/}
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='newTagValue'>Value</Label>
|
||||
<Input
|
||||
id='newTagValue'
|
||||
value={editTagForm.value}
|
||||
onChange={(e) => setEditTagForm({ ...editTagForm, value: e.target.value })}
|
||||
placeholder='Enter tag value'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSaveTag) {
|
||||
e.preventDefault()
|
||||
saveDocumentTag()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditingTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{kbTagDefinitions.length >= MAX_TAG_SLOTS &&
|
||||
!kbTagDefinitions.find(
|
||||
(def) =>
|
||||
def.displayName.toLowerCase() === editTagForm.displayName.toLowerCase()
|
||||
) && (
|
||||
<div className='rounded-[4px] border border-amber-500/50 bg-amber-500/10 p-[8px]'>
|
||||
<p className='text-[11px] text-amber-600 dark:text-amber-400'>
|
||||
Maximum tag definitions reached. You can still use existing tag
|
||||
definitions, but cannot create new ones.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex gap-[8px]'>
|
||||
<Button variant='default' onClick={cancelEditingTag} className='flex-1'>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={saveDocumentTag}
|
||||
className='flex-1'
|
||||
disabled={
|
||||
!canSaveTag ||
|
||||
isSavingTag ||
|
||||
(kbTagDefinitions.length >= MAX_TAG_SLOTS &&
|
||||
!kbTagDefinitions.find(
|
||||
(def) =>
|
||||
def.displayName.toLowerCase() ===
|
||||
editTagForm.displayName.toLowerCase()
|
||||
))
|
||||
}
|
||||
>
|
||||
{isSavingTag ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Tag'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => handleClose(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { AlertCircle, ChevronDown, ChevronUp, Loader2, X } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
@@ -171,13 +172,15 @@ export function EditChunkModal({
|
||||
return (
|
||||
<>
|
||||
<Modal open={isOpen} onOpenChange={handleCloseAttempt}>
|
||||
<ModalContent className='h-[74vh] sm:max-w-[600px]'>
|
||||
<ModalContent size='lg'>
|
||||
<div className='flex items-center justify-between px-[16px] py-[10px]'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='font-medium text-[16px] text-[var(--text-primary)]'>Edit Chunk</span>
|
||||
<DialogPrimitive.Title className='font-medium text-[16px] text-[var(--text-primary)]'>
|
||||
Edit Chunk #{chunk.chunkIndex}
|
||||
</DialogPrimitive.Title>
|
||||
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
{/* Navigation Controls */}
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger
|
||||
asChild
|
||||
@@ -188,9 +191,9 @@ export function EditChunkModal({
|
||||
variant='ghost'
|
||||
onClick={() => handleNavigate('prev')}
|
||||
disabled={!canNavigatePrev || isNavigating || isSaving}
|
||||
className='h-8 w-8 p-0'
|
||||
className='h-[16px] w-[16px] p-0'
|
||||
>
|
||||
<ChevronUp className='h-4 w-4' />
|
||||
<ChevronUp className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom'>
|
||||
@@ -209,9 +212,9 @@ export function EditChunkModal({
|
||||
variant='ghost'
|
||||
onClick={() => handleNavigate('next')}
|
||||
disabled={!canNavigateNext || isNavigating || isSaving}
|
||||
className='h-8 w-8 p-0'
|
||||
className='h-[16px] w-[16px] p-0'
|
||||
>
|
||||
<ChevronDown className='h-4 w-4' />
|
||||
<ChevronDown className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom'>
|
||||
@@ -222,29 +225,21 @@ export function EditChunkModal({
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant='ghost' className='h-[16px] w-[16px] p-0' onClick={handleCloseAttempt}>
|
||||
<X className='h-[16px] w-[16px]' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[16px] w-[16px] p-0'
|
||||
onClick={handleCloseAttempt}
|
||||
>
|
||||
<X className='h-[16px] w-[16px]' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className='flex min-h-0 flex-1 flex-col'>
|
||||
<ModalBody>
|
||||
<div className='space-y-[12px]'>
|
||||
{/* Document Info Section */}
|
||||
<div className='flex items-center gap-3 rounded-lg border p-4'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='font-medium text-[var(--text-primary)] text-sm'>
|
||||
{document?.filename || 'Unknown Document'}
|
||||
</p>
|
||||
<p className='text-[var(--text-tertiary)] text-xs'>
|
||||
Editing chunk #{chunk.chunkIndex} • Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form>
|
||||
<ModalBody className='!pb-[16px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className='flex items-center gap-2 rounded-md border border-[var(--text-error)]/50 bg-[var(--text-error)]/10 p-3'>
|
||||
@@ -254,25 +249,18 @@ export function EditChunkModal({
|
||||
)}
|
||||
|
||||
{/* Content Input Section */}
|
||||
<div className='space-y-[8px]'>
|
||||
<Label
|
||||
htmlFor='content'
|
||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
||||
>
|
||||
Chunk Content
|
||||
</Label>
|
||||
<Textarea
|
||||
id='content'
|
||||
value={editedContent}
|
||||
onChange={(e) => setEditedContent(e.target.value)}
|
||||
placeholder={
|
||||
userPermissions.canEdit ? 'Enter chunk content...' : 'Read-only view'
|
||||
}
|
||||
rows={10}
|
||||
disabled={isSaving || isNavigating || !userPermissions.canEdit}
|
||||
readOnly={!userPermissions.canEdit}
|
||||
/>
|
||||
</div>
|
||||
<Label htmlFor='content'>Chunk</Label>
|
||||
<Textarea
|
||||
id='content'
|
||||
value={editedContent}
|
||||
onChange={(e) => setEditedContent(e.target.value)}
|
||||
placeholder={
|
||||
userPermissions.canEdit ? 'Enter chunk content...' : 'Read-only view'
|
||||
}
|
||||
rows={20}
|
||||
disabled={isSaving || isNavigating || !userPermissions.canEdit}
|
||||
readOnly={!userPermissions.canEdit}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
@@ -298,7 +286,7 @@ export function EditChunkModal({
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Changes'
|
||||
'Save'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
@@ -309,7 +297,7 @@ export function EditChunkModal({
|
||||
|
||||
{/* Unsaved Changes Alert */}
|
||||
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Unsaved Changes</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { CreateChunkModal } from './create-chunk-modal/create-chunk-modal'
|
||||
export { DeleteChunkModal } from './delete-chunk-modal/delete-chunk-modal'
|
||||
export { DocumentLoading } from './document-loading'
|
||||
export { DocumentTagsModal } from './document-tags-modal/document-tags-modal'
|
||||
export { EditChunkModal } from './edit-chunk-modal/edit-chunk-modal'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Circle, CircleOff, Trash2 } from 'lucide-react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Circle, CircleOff } from 'lucide-react'
|
||||
import { Button, Tooltip, Trash2 } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
|
||||
@@ -42,23 +41,22 @@ export function ActionBar({
|
||||
transition={{ duration: 0.2 }}
|
||||
className={cn('-translate-x-1/2 fixed bottom-6 left-1/2 z-50 transform', className)}
|
||||
>
|
||||
<div className='flex items-center gap-3 rounded-lg border border-gray-200 bg-background px-4 py-2 shadow-sm dark:border-gray-800'>
|
||||
<span className='text-gray-500 text-sm'>{selectedCount} selected</span>
|
||||
<div className='flex items-center gap-[8px] rounded-[10px] border border-[var(--border-strong)] bg-[var(--surface-1)] p-[8px]'>
|
||||
<span className='px-[4px] text-[13px] text-[var(--text-muted)]'>
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
|
||||
<div className='h-4 w-px bg-gray-200 dark:bg-gray-800' />
|
||||
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='flex items-center gap-[5px]'>
|
||||
{showEnableButton && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={onEnable}
|
||||
disabled={isLoading}
|
||||
className='text-gray-500 hover:text-gray-700'
|
||||
className='hover:!text-[var(--text-inverse)] h-[28px] w-[28px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
|
||||
>
|
||||
<Circle className='h-4 w-4' />
|
||||
<Circle className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
@@ -72,12 +70,11 @@ export function ActionBar({
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={onDisable}
|
||||
disabled={isLoading}
|
||||
className='text-gray-500 hover:text-gray-700'
|
||||
className='hover:!text-[var(--text-inverse)] h-[28px] w-[28px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
|
||||
>
|
||||
<CircleOff className='h-4 w-4' />
|
||||
<CircleOff className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
@@ -91,12 +88,11 @@ export function ActionBar({
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={onDelete}
|
||||
disabled={isLoading}
|
||||
className='text-gray-500 hover:text-red-600'
|
||||
className='hover:!text-[var(--text-inverse)] h-[28px] w-[28px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
<Trash2 className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>Delete items</Tooltip.Content>
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { AlertCircle, Loader2, RotateCcw, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@/components/emcn'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
|
||||
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
|
||||
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
|
||||
|
||||
const logger = createLogger('AddDocumentsModal')
|
||||
|
||||
interface FileWithPreview extends File {
|
||||
preview: string
|
||||
}
|
||||
|
||||
interface AddDocumentsModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
knowledgeBaseId: string
|
||||
chunkingConfig?: {
|
||||
maxSize: number
|
||||
minSize: number
|
||||
overlap: number
|
||||
}
|
||||
onUploadComplete?: () => void
|
||||
}
|
||||
|
||||
export function AddDocumentsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
knowledgeBaseId,
|
||||
chunkingConfig,
|
||||
onUploadComplete,
|
||||
}: AddDocumentsModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [files, setFiles] = useState<FileWithPreview[]>([])
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [dragCounter, setDragCounter] = useState(0)
|
||||
const [retryingIndexes, setRetryingIndexes] = useState<Set<number>>(new Set())
|
||||
|
||||
const { isUploading, uploadProgress, uploadFiles, clearError } = useKnowledgeUpload({
|
||||
workspaceId,
|
||||
onUploadComplete: () => {
|
||||
logger.info(`Successfully uploaded ${files.length} files`)
|
||||
onUploadComplete?.()
|
||||
handleClose()
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
files.forEach((file) => {
|
||||
if (file.preview) {
|
||||
URL.revokeObjectURL(file.preview)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [files])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFiles([])
|
||||
setFileError(null)
|
||||
setIsDragging(false)
|
||||
setDragCounter(0)
|
||||
setRetryingIndexes(new Set())
|
||||
clearError()
|
||||
}
|
||||
}, [open, clearError])
|
||||
|
||||
const handleClose = () => {
|
||||
if (isUploading) return
|
||||
setFiles([])
|
||||
setFileError(null)
|
||||
clearError()
|
||||
setIsDragging(false)
|
||||
setDragCounter(0)
|
||||
setRetryingIndexes(new Set())
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const processFiles = async (fileList: FileList | File[]) => {
|
||||
setFileError(null)
|
||||
|
||||
if (!fileList || fileList.length === 0) return
|
||||
|
||||
try {
|
||||
const newFiles: FileWithPreview[] = []
|
||||
let hasError = false
|
||||
|
||||
for (const file of Array.from(fileList)) {
|
||||
const validationError = validateKnowledgeBaseFile(file)
|
||||
if (validationError) {
|
||||
setFileError(validationError)
|
||||
hasError = true
|
||||
continue
|
||||
}
|
||||
|
||||
const fileWithPreview = Object.assign(file, {
|
||||
preview: URL.createObjectURL(file),
|
||||
}) as FileWithPreview
|
||||
|
||||
newFiles.push(fileWithPreview)
|
||||
}
|
||||
|
||||
if (!hasError && newFiles.length > 0) {
|
||||
setFiles((prev) => [...prev, ...newFiles])
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing files:', error)
|
||||
setFileError('An error occurred while processing files. Please try again.')
|
||||
} finally {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
await processFiles(e.target.files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragCounter((prev) => {
|
||||
const newCount = prev + 1
|
||||
if (newCount === 1) {
|
||||
setIsDragging(true)
|
||||
}
|
||||
return newCount
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragCounter((prev) => {
|
||||
const newCount = prev - 1
|
||||
if (newCount === 0) {
|
||||
setIsDragging(false)
|
||||
}
|
||||
return newCount
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
setDragCounter(0)
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
await processFiles(e.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => {
|
||||
URL.revokeObjectURL(prev[index].preview)
|
||||
return prev.filter((_, i) => i !== index)
|
||||
})
|
||||
}
|
||||
|
||||
const handleRetryFile = async (index: number) => {
|
||||
const fileToRetry = files[index]
|
||||
if (!fileToRetry) return
|
||||
|
||||
setRetryingIndexes((prev) => new Set(prev).add(index))
|
||||
|
||||
try {
|
||||
await uploadFiles([fileToRetry], knowledgeBaseId, {
|
||||
chunkSize: chunkingConfig?.maxSize || 1024,
|
||||
minCharactersPerChunk: chunkingConfig?.minSize || 1,
|
||||
chunkOverlap: chunkingConfig?.overlap || 200,
|
||||
recipe: 'default',
|
||||
})
|
||||
removeFile(index)
|
||||
} catch (error) {
|
||||
logger.error('Error retrying file upload:', error)
|
||||
} finally {
|
||||
setRetryingIndexes((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(index)
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (files.length === 0) return
|
||||
|
||||
try {
|
||||
await uploadFiles(files, knowledgeBaseId, {
|
||||
chunkSize: chunkingConfig?.maxSize || 1024,
|
||||
minCharactersPerChunk: chunkingConfig?.minSize || 1,
|
||||
chunkOverlap: chunkingConfig?.overlap || 200,
|
||||
recipe: 'default',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error uploading files:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>Add Documents</ModalHeader>
|
||||
|
||||
<ModalBody className='!pb-[16px]'>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='space-y-[12px]'>
|
||||
{fileError && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{fileError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label>Upload Documents</Label>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
'!bg-[var(--surface-1)] hover:!bg-[var(--surface-4)] w-full justify-center border border-[var(--c-575757)] border-dashed py-[10px]',
|
||||
isDragging && 'border-[var(--brand-primary-hex)]'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept={ACCEPT_ATTRIBUTE}
|
||||
onChange={handleFileChange}
|
||||
className='hidden'
|
||||
multiple
|
||||
/>
|
||||
<div className='flex flex-col gap-[2px] text-center'>
|
||||
<span className='text-[var(--text-primary)]'>
|
||||
{isDragging ? 'Drop files here' : 'Drop files here or click to browse'}
|
||||
</span>
|
||||
<span className='text-[11px] text-[var(--text-tertiary)]'>
|
||||
PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML (max 100MB each)
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className='space-y-2'>
|
||||
<Label>Selected Files</Label>
|
||||
<div className='space-y-2'>
|
||||
{files.map((file, index) => {
|
||||
const fileStatus = uploadProgress.fileStatuses?.[index]
|
||||
const isFailed = fileStatus?.status === 'failed'
|
||||
const isRetrying = retryingIndexes.has(index)
|
||||
const isProcessing = fileStatus?.status === 'uploading' || isRetrying
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className='flex items-center gap-2 rounded-[4px] border p-[8px]'
|
||||
>
|
||||
{isFailed && !isRetrying && (
|
||||
<AlertCircle className='h-4 w-4 flex-shrink-0 text-[var(--text-error)]' />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-[12px]',
|
||||
isFailed && !isRetrying && 'text-[var(--text-error)]'
|
||||
)}
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className='flex-shrink-0 text-[11px] text-[var(--text-muted)]'>
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||
{isFailed && !isRetrying && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
onClick={() => handleRetryFile(index)}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<RotateCcw className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
)}
|
||||
{isProcessing ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin text-[var(--text-muted)]' />
|
||||
) : (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='h-4 w-4 p-0'
|
||||
onClick={() => removeFile(index)}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<X className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={handleClose} type='button' disabled={isUploading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
type='button'
|
||||
onClick={handleUpload}
|
||||
disabled={files.length === 0 || isUploading}
|
||||
>
|
||||
{isUploading
|
||||
? uploadProgress.stage === 'uploading'
|
||||
? `Uploading ${uploadProgress.filesCompleted}/${uploadProgress.totalFiles}...`
|
||||
: uploadProgress.stage === 'processing'
|
||||
? 'Processing...'
|
||||
: 'Uploading...'
|
||||
: 'Upload'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Trash,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import {
|
||||
type TagDefinition,
|
||||
useKnowledgeBaseTagDefinitions,
|
||||
} from '@/hooks/use-knowledge-base-tag-definitions'
|
||||
|
||||
const logger = createLogger('BaseTagsModal')
|
||||
|
||||
interface TagUsageData {
|
||||
tagName: string
|
||||
tagSlot: string
|
||||
documentCount: number
|
||||
documents: Array<{ id: string; name: string; tagValue: string }>
|
||||
}
|
||||
|
||||
interface DocumentListProps {
|
||||
documents: Array<{ id: string; name: string; tagValue: string }>
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
/** Displays a list of documents affected by tag operations */
|
||||
function DocumentList({ documents, totalCount }: DocumentListProps) {
|
||||
const displayLimit = 5
|
||||
const hasMore = totalCount > displayLimit
|
||||
|
||||
return (
|
||||
<div className='rounded-[4px] border'>
|
||||
<div className='max-h-[160px] overflow-y-auto'>
|
||||
{documents.slice(0, displayLimit).map((doc) => {
|
||||
const DocumentIcon = getDocumentIcon('', doc.name)
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
className='flex items-center gap-[8px] border-b p-[8px] last:border-b-0'
|
||||
>
|
||||
<DocumentIcon className='h-4 w-4 flex-shrink-0 text-[var(--text-muted)]' />
|
||||
<span className='min-w-0 max-w-[120px] truncate text-[12px] text-[var(--text-primary)]'>
|
||||
{doc.name}
|
||||
</span>
|
||||
{doc.tagValue && (
|
||||
<>
|
||||
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
|
||||
<span className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-muted)]'>
|
||||
{doc.tagValue}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{hasMore && (
|
||||
<div className='p-[8px] text-[11px] text-[var(--text-muted)]'>
|
||||
and {totalCount - displayLimit} more documents
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface BaseTagsModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
knowledgeBaseId: string
|
||||
}
|
||||
|
||||
export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsModalProps) {
|
||||
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
|
||||
useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
|
||||
const [deleteTagDialogOpen, setDeleteTagDialogOpen] = useState(false)
|
||||
const [selectedTag, setSelectedTag] = useState<TagDefinition | null>(null)
|
||||
const [viewDocumentsDialogOpen, setViewDocumentsDialogOpen] = useState(false)
|
||||
const [isDeletingTag, setIsDeletingTag] = useState(false)
|
||||
const [tagUsageData, setTagUsageData] = useState<TagUsageData[]>([])
|
||||
const [isCreatingTag, setIsCreatingTag] = useState(false)
|
||||
const [isSavingTag, setIsSavingTag] = useState(false)
|
||||
const [createTagForm, setCreateTagForm] = useState({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
})
|
||||
|
||||
const fetchTagUsage = useCallback(async () => {
|
||||
if (!knowledgeBaseId) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-usage`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch tag usage')
|
||||
}
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
setTagUsageData(result.data)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching tag usage:', error)
|
||||
}
|
||||
}, [knowledgeBaseId])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchTagUsage()
|
||||
}
|
||||
}, [open, fetchTagUsage])
|
||||
|
||||
const getTagUsage = (tagSlot: string): TagUsageData => {
|
||||
return (
|
||||
tagUsageData.find((usage) => usage.tagSlot === tagSlot) || {
|
||||
tagName: '',
|
||||
tagSlot,
|
||||
documentCount: 0,
|
||||
documents: [],
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleDeleteTagClick = async (tag: TagDefinition) => {
|
||||
setSelectedTag(tag)
|
||||
await fetchTagUsage()
|
||||
setDeleteTagDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleViewDocuments = async (tag: TagDefinition) => {
|
||||
setSelectedTag(tag)
|
||||
await fetchTagUsage()
|
||||
setViewDocumentsDialogOpen(true)
|
||||
}
|
||||
|
||||
const openTagCreator = () => {
|
||||
setCreateTagForm({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
})
|
||||
setIsCreatingTag(true)
|
||||
}
|
||||
|
||||
const cancelCreatingTag = () => {
|
||||
setCreateTagForm({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
})
|
||||
setIsCreatingTag(false)
|
||||
}
|
||||
|
||||
const hasTagNameConflict = (name: string) => {
|
||||
if (!name.trim()) return false
|
||||
return kbTagDefinitions.some(
|
||||
(tag) => tag.displayName.toLowerCase() === name.trim().toLowerCase()
|
||||
)
|
||||
}
|
||||
|
||||
const tagNameConflict =
|
||||
isCreatingTag && !isSavingTag && hasTagNameConflict(createTagForm.displayName)
|
||||
|
||||
const canSaveTag = () => {
|
||||
return createTagForm.displayName.trim() && !hasTagNameConflict(createTagForm.displayName)
|
||||
}
|
||||
|
||||
const saveTagDefinition = async () => {
|
||||
if (!canSaveTag()) return
|
||||
|
||||
setIsSavingTag(true)
|
||||
try {
|
||||
const usedSlots = new Set(kbTagDefinitions.map((def) => def.tagSlot))
|
||||
const availableSlot = (
|
||||
['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const
|
||||
).find((slot) => !usedSlots.has(slot))
|
||||
|
||||
if (!availableSlot) {
|
||||
throw new Error('No available tag slots')
|
||||
}
|
||||
|
||||
const newTagDefinition = {
|
||||
tagSlot: availableSlot,
|
||||
displayName: createTagForm.displayName.trim(),
|
||||
fieldType: createTagForm.fieldType,
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-definitions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(newTagDefinition),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create tag definition')
|
||||
}
|
||||
|
||||
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
|
||||
|
||||
setCreateTagForm({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
})
|
||||
setIsCreatingTag(false)
|
||||
} catch (error) {
|
||||
logger.error('Error creating tag definition:', error)
|
||||
} finally {
|
||||
setIsSavingTag(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDeleteTag = async () => {
|
||||
if (!selectedTag) return
|
||||
|
||||
setIsDeletingTag(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/tag-definitions/${selectedTag.id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Failed to delete tag definition: ${response.status} ${errorText}`)
|
||||
}
|
||||
|
||||
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
|
||||
|
||||
setDeleteTagDialogOpen(false)
|
||||
setSelectedTag(null)
|
||||
} catch (error) {
|
||||
logger.error('Error deleting tag definition:', error)
|
||||
} finally {
|
||||
setIsDeletingTag(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedTagUsage = selectedTag ? getTagUsage(selectedTag.tagSlot) : null
|
||||
|
||||
const handleClose = (openState: boolean) => {
|
||||
if (!openState) {
|
||||
setIsCreatingTag(false)
|
||||
setCreateTagForm({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
})
|
||||
}
|
||||
onOpenChange(openState)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span>Tags</span>
|
||||
</div>
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody className='!pb-[16px]'>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='space-y-[8px]'>
|
||||
<Label>
|
||||
Tags:{' '}
|
||||
<span className='pl-[6px] text-[var(--text-tertiary)]'>
|
||||
{kbTagDefinitions.length}/{MAX_TAG_SLOTS} slots used
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
{kbTagDefinitions.length === 0 && !isCreatingTag && (
|
||||
<div className='rounded-[6px] border p-[16px] text-center'>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
No tag definitions yet. Create your first tag to organize documents.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kbTagDefinitions.map((tag) => {
|
||||
const usage = getTagUsage(tag.tagSlot)
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className='flex cursor-pointer items-center gap-2 rounded-[4px] border p-[8px] hover:bg-[var(--surface-2)]'
|
||||
onClick={() => handleViewDocuments(tag)}
|
||||
>
|
||||
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
|
||||
{tag.displayName}
|
||||
</span>
|
||||
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
|
||||
<span className='min-w-0 flex-1 text-[11px] text-[var(--text-muted)]'>
|
||||
{usage.documentCount} document{usage.documentCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteTagClick(tag)
|
||||
}}
|
||||
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{!isCreatingTag && (
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={openTagCreator}
|
||||
disabled={kbTagDefinitions.length >= MAX_TAG_SLOTS}
|
||||
className='w-full'
|
||||
>
|
||||
Add Tag
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCreatingTag && (
|
||||
<div className='space-y-[8px] rounded-[6px] border p-[12px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='tagName'>Tag Name</Label>
|
||||
<Input
|
||||
id='tagName'
|
||||
value={createTagForm.displayName}
|
||||
onChange={(e) =>
|
||||
setCreateTagForm({ ...createTagForm, displayName: e.target.value })
|
||||
}
|
||||
placeholder='Enter tag name'
|
||||
className={cn(tagNameConflict && 'border-[var(--text-error)]')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSaveTag()) {
|
||||
e.preventDefault()
|
||||
saveTagDefinition()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelCreatingTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{tagNameConflict && (
|
||||
<span className='text-[11px] text-[var(--text-error)]'>
|
||||
A tag with this name already exists
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type selector commented out - only "text" type is currently supported
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='tagType'>Type</Label>
|
||||
<Input id='tagType' value='Text' disabled className='capitalize' />
|
||||
</div>
|
||||
*/}
|
||||
|
||||
<div className='flex gap-[8px]'>
|
||||
<Button variant='default' onClick={cancelCreatingTag} className='flex-1'>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={saveTagDefinition}
|
||||
className='flex-1'
|
||||
disabled={!canSaveTag() || isSavingTag}
|
||||
>
|
||||
{isSavingTag ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Tag'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => handleClose(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Tag Confirmation Dialog */}
|
||||
<Modal open={deleteTagDialogOpen} onOpenChange={setDeleteTagDialogOpen}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Tag</ModalHeader>
|
||||
<ModalBody className='!pb-[16px]'>
|
||||
<div className='space-y-[8px]'>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
Are you sure you want to delete the "{selectedTag?.displayName}" tag? This will
|
||||
remove this tag from {selectedTagUsage?.documentCount || 0} document
|
||||
{selectedTagUsage?.documentCount !== 1 ? 's' : ''}.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
|
||||
{selectedTagUsage && selectedTagUsage.documentCount > 0 && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label>Affected documents:</Label>
|
||||
<DocumentList
|
||||
documents={selectedTagUsage.documents}
|
||||
totalCount={selectedTagUsage.documentCount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='default'
|
||||
disabled={isDeletingTag}
|
||||
onClick={() => setDeleteTagDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={confirmDeleteTag}
|
||||
disabled={isDeletingTag}
|
||||
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
|
||||
>
|
||||
{isDeletingTag ? <>Deleting...</> : 'Delete Tag'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* View Documents Dialog */}
|
||||
<Modal open={viewDocumentsDialogOpen} onOpenChange={setViewDocumentsDialogOpen}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Documents using "{selectedTag?.displayName}"</ModalHeader>
|
||||
<ModalBody className='!pb-[16px]'>
|
||||
<div className='space-y-[8px]'>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
{selectedTagUsage?.documentCount || 0} document
|
||||
{selectedTagUsage?.documentCount !== 1 ? 's are' : ' is'} currently using this tag
|
||||
definition.
|
||||
</p>
|
||||
|
||||
{selectedTagUsage?.documentCount === 0 ? (
|
||||
<div className='rounded-[6px] border p-[16px] text-center'>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
This tag definition is not being used by any documents. You can safely delete it
|
||||
to free up the tag slot.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DocumentList
|
||||
documents={selectedTagUsage?.documents || []}
|
||||
totalCount={selectedTagUsage?.documentCount || 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => setViewDocumentsDialogOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
export { ActionBar } from './action-bar/action-bar'
|
||||
export { KnowledgeBaseLoading } from './knowledge-base-loading/knowledge-base-loading'
|
||||
export { UploadModal } from './upload-modal/upload-modal'
|
||||
export { AddDocumentsModal } from './add-documents-modal/add-documents-modal'
|
||||
export { BaseTagsModal } from './base-tags-modal/base-tags-modal'
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import {
|
||||
DocumentTableSkeleton,
|
||||
KnowledgeHeader,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
|
||||
interface KnowledgeBaseLoadingProps {
|
||||
knowledgeBaseName: string
|
||||
}
|
||||
|
||||
export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoadingProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params?.workspaceId as string
|
||||
|
||||
const breadcrumbs = [
|
||||
{
|
||||
id: 'knowledge-root',
|
||||
label: 'Knowledge',
|
||||
href: `/workspace/${workspaceId}/knowledge`,
|
||||
},
|
||||
{
|
||||
id: 'knowledge-base-loading',
|
||||
label: knowledgeBaseName,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className='flex h-[100vh] flex-col pl-64'>
|
||||
{/* Fixed Header with Breadcrumbs */}
|
||||
<KnowledgeHeader breadcrumbs={breadcrumbs} />
|
||||
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
||||
{/* Main Content */}
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<div className='px-6 pb-6'>
|
||||
{/* Search and Create Section */}
|
||||
<div className='mb-4 flex items-center justify-between pt-1'>
|
||||
<div className='relative max-w-md flex-1'>
|
||||
<div className='relative flex items-center'>
|
||||
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-[18px] w-[18px] transform text-muted-foreground' />
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Search documents...'
|
||||
disabled
|
||||
className='h-10 w-full rounded-md border bg-background px-9 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* Add Documents Button - disabled state */}
|
||||
<Button disabled variant='primary' className='flex items-center gap-1'>
|
||||
<div className='h-3.5 w-3.5 animate-pulse rounded bg-primary-foreground/30' />
|
||||
<span>Add Documents</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table container */}
|
||||
<DocumentTableSkeleton isSidebarCollapsed={false} rowCount={8} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { AlertCircle, Check, Loader2, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
|
||||
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
|
||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
|
||||
|
||||
const logger = createLogger('UploadModal')
|
||||
|
||||
interface FileWithPreview extends File {
|
||||
preview: string
|
||||
}
|
||||
|
||||
interface UploadModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
knowledgeBaseId: string
|
||||
chunkingConfig?: {
|
||||
maxSize: number
|
||||
minSize: number
|
||||
overlap: number
|
||||
}
|
||||
onUploadComplete?: () => void
|
||||
}
|
||||
|
||||
export function UploadModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
knowledgeBaseId,
|
||||
chunkingConfig,
|
||||
onUploadComplete,
|
||||
}: UploadModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [files, setFiles] = useState<FileWithPreview[]>([])
|
||||
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const { isUploading, uploadProgress, uploadError, uploadFiles, clearError } = useKnowledgeUpload({
|
||||
workspaceId,
|
||||
onUploadComplete: () => {
|
||||
logger.info(`Successfully uploaded ${files.length} files`)
|
||||
onUploadComplete?.()
|
||||
handleClose()
|
||||
},
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
if (isUploading) return // Prevent closing during upload
|
||||
|
||||
setFiles([])
|
||||
setFileError(null)
|
||||
clearError()
|
||||
setIsDragging(false)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
return validateKnowledgeBaseFile(file)
|
||||
}
|
||||
|
||||
const processFiles = (fileList: FileList | File[]) => {
|
||||
setFileError(null)
|
||||
const newFiles: FileWithPreview[] = []
|
||||
|
||||
for (const file of Array.from(fileList)) {
|
||||
const error = validateFile(file)
|
||||
if (error) {
|
||||
setFileError(error)
|
||||
return
|
||||
}
|
||||
|
||||
const fileWithPreview = Object.assign(file, {
|
||||
preview: URL.createObjectURL(file),
|
||||
})
|
||||
newFiles.push(fileWithPreview)
|
||||
}
|
||||
|
||||
setFiles((prev) => [...prev, ...newFiles])
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => {
|
||||
const newFiles = [...prev]
|
||||
const removedFile = newFiles.splice(index, 1)[0]
|
||||
if (removedFile.preview) {
|
||||
URL.revokeObjectURL(removedFile.preview)
|
||||
}
|
||||
return newFiles
|
||||
})
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
processFiles(e.target.files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
if (e.dataTransfer.files) {
|
||||
processFiles(e.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (files.length === 0) return
|
||||
|
||||
try {
|
||||
await uploadFiles(files, knowledgeBaseId, {
|
||||
chunkSize: chunkingConfig?.maxSize || 1024,
|
||||
minCharactersPerChunk: chunkingConfig?.minSize || 1,
|
||||
chunkOverlap: chunkingConfig?.overlap || 200,
|
||||
recipe: 'default',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error uploading files:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getFileIcon = (mimeType: string, filename: string) => {
|
||||
const IconComponent = getDocumentIcon(mimeType, filename)
|
||||
return <IconComponent className='h-10 w-8' />
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent className='max-h-[95vh] sm:max-w-[600px]'>
|
||||
<ModalHeader>Upload Documents</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div className='space-y-[12px]'>
|
||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Select Files
|
||||
</Label>
|
||||
|
||||
{files.length === 0 ? (
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`relative flex cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-dashed p-8 text-center transition-colors ${
|
||||
isDragging
|
||||
? 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)]/5'
|
||||
: 'border-[var(--c-575757)] hover:border-[var(--text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept={ACCEPT_ATTRIBUTE}
|
||||
onChange={handleFileChange}
|
||||
className='hidden'
|
||||
multiple
|
||||
/>
|
||||
<div className='space-y-2'>
|
||||
<p className='font-medium text-[var(--text-primary)] text-sm'>
|
||||
{isDragging ? 'Drop files here!' : 'Drop files here or click to browse'}
|
||||
</p>
|
||||
<p className='text-[var(--text-tertiary)] text-xs'>
|
||||
Supports PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSON, YAML,
|
||||
YML (max 100MB each)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`cursor-pointer rounded-md border border-dashed p-3 text-center transition-colors ${
|
||||
isDragging
|
||||
? 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)]/5'
|
||||
: 'border-[var(--c-575757)] hover:border-[var(--text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept={ACCEPT_ATTRIBUTE}
|
||||
onChange={handleFileChange}
|
||||
className='hidden'
|
||||
multiple
|
||||
/>
|
||||
<p className='text-[var(--text-primary)] text-sm'>
|
||||
{isDragging ? 'Drop more files here!' : 'Drop more files or click to browse'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='max-h-80 space-y-2 overflow-auto'>
|
||||
{files.map((file, index) => {
|
||||
const fileStatus = uploadProgress.fileStatuses?.[index]
|
||||
const isCurrentlyUploading = fileStatus?.status === 'uploading'
|
||||
const isCompleted = fileStatus?.status === 'completed'
|
||||
const isFailed = fileStatus?.status === 'failed'
|
||||
|
||||
return (
|
||||
<div key={index} className='rounded-md border p-3'>
|
||||
<div className='flex items-center gap-3'>
|
||||
{getFileIcon(file.type, file.name)}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{isCurrentlyUploading && (
|
||||
<Loader2 className='h-4 w-4 animate-spin text-[var(--brand-primary-hex)]' />
|
||||
)}
|
||||
{isCompleted && (
|
||||
<Check className='h-4 w-4 text-[var(--text-success)]' />
|
||||
)}
|
||||
{isFailed && <X className='h-4 w-4 text-[var(--text-error)]' />}
|
||||
<p className='truncate font-medium text-[var(--text-primary)] text-sm'>
|
||||
{file.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<p className='text-[var(--text-tertiary)] text-xs'>
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
{isCurrentlyUploading && (
|
||||
<div className='min-w-0 max-w-32 flex-1'>
|
||||
<Progress value={fileStatus?.progress || 0} className='h-1' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isFailed && fileStatus?.error && (
|
||||
<p className='mt-1 text-[var(--text-error)] text-xs'>
|
||||
{fileStatus.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => removeFile(index)}
|
||||
disabled={isUploading}
|
||||
className='h-8 w-8 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show upload error first, then file error only if no upload error */}
|
||||
{uploadError && (
|
||||
<div className='rounded-md border border-[var(--text-error)]/50 bg-[var(--text-error)]/10 px-3 py-2'>
|
||||
<div className='flex items-start gap-2'>
|
||||
<AlertCircle className='mt-0.5 h-4 w-4 shrink-0 text-[var(--text-error)]' />
|
||||
<div className='flex-1 text-[var(--text-error)] text-sm'>
|
||||
{uploadError.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileError && !uploadError && (
|
||||
<div className='rounded-md border border-[var(--text-error)]/50 bg-[var(--text-error)]/10 px-3 py-2 text-[var(--text-error)] text-sm'>
|
||||
{fileError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={handleClose} disabled={isUploading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleUpload}
|
||||
disabled={files.length === 0 || isUploading}
|
||||
>
|
||||
{isUploading
|
||||
? uploadProgress.stage === 'uploading'
|
||||
? `Uploading ${uploadProgress.filesCompleted + 1}/${uploadProgress.totalFiles}...`
|
||||
: uploadProgress.stage === 'processing'
|
||||
? 'Processing...'
|
||||
: 'Uploading...'
|
||||
: `Upload ${files.length} file${files.length !== 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Badge, DocumentAttachment, Tooltip } from '@/components/emcn'
|
||||
|
||||
interface BaseCardProps {
|
||||
id?: string
|
||||
title: string
|
||||
docCount: number
|
||||
description: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date string to relative time (e.g., "2h ago", "3d ago")
|
||||
*/
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return 'just now'
|
||||
}
|
||||
if (diffInSeconds < 3600) {
|
||||
const minutes = Math.floor(diffInSeconds / 60)
|
||||
return `${minutes}m ago`
|
||||
}
|
||||
if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600)
|
||||
return `${hours}h ago`
|
||||
}
|
||||
if (diffInSeconds < 604800) {
|
||||
const days = Math.floor(diffInSeconds / 86400)
|
||||
return `${days}d ago`
|
||||
}
|
||||
if (diffInSeconds < 2592000) {
|
||||
const weeks = Math.floor(diffInSeconds / 604800)
|
||||
return `${weeks}w ago`
|
||||
}
|
||||
if (diffInSeconds < 31536000) {
|
||||
const months = Math.floor(diffInSeconds / 2592000)
|
||||
return `${months}mo ago`
|
||||
}
|
||||
const years = Math.floor(diffInSeconds / 31536000)
|
||||
return `${years}y ago`
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date string to absolute format for tooltip display
|
||||
*/
|
||||
function formatAbsoluteDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton placeholder for a knowledge base card
|
||||
*/
|
||||
export function BaseCardSkeleton() {
|
||||
return (
|
||||
<div className='group flex h-full cursor-pointer flex-col gap-[12px] rounded-[4px] bg-[var(--surface-elevated)] px-[8px] py-[6px] transition-colors hover:bg-[var(--surface-5)]'>
|
||||
<div className='flex items-center justify-between gap-[8px]'>
|
||||
<div className='h-[17px] w-[120px] animate-pulse rounded-[4px] bg-[var(--surface-9)]' />
|
||||
<div className='h-[22px] w-[90px] animate-pulse rounded-[4px] bg-[var(--surface-5)]' />
|
||||
</div>
|
||||
|
||||
<div className='flex flex-1 flex-col gap-[8px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<div className='h-[12px] w-[12px] animate-pulse rounded-[2px] bg-[var(--surface-9)]' />
|
||||
<div className='h-[15px] w-[45px] animate-pulse rounded-[4px] bg-[var(--surface-9)]' />
|
||||
</div>
|
||||
<div className='h-[15px] w-[120px] animate-pulse rounded-[4px] bg-[var(--surface-5)]' />
|
||||
</div>
|
||||
|
||||
<div className='h-0 w-full border-[var(--divider)] border-t' />
|
||||
|
||||
<div className='flex h-[36px] flex-col gap-[6px]'>
|
||||
<div className='h-[15px] w-full animate-pulse rounded-[4px] bg-[var(--surface-5)]' />
|
||||
<div className='h-[15px] w-[75%] animate-pulse rounded-[4px] bg-[var(--surface-5)]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders multiple knowledge base card skeletons as a fragment
|
||||
*/
|
||||
export function BaseCardSkeletonGrid({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<BaseCardSkeleton key={i} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Knowledge base card component displaying overview information
|
||||
*/
|
||||
export function BaseCard({ id, title, docCount, description, updatedAt }: BaseCardProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params?.workspaceId as string
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
kbName: title,
|
||||
})
|
||||
const href = `/workspace/${workspaceId}/knowledge/${id || title.toLowerCase().replace(/\s+/g, '-')}?${searchParams.toString()}`
|
||||
|
||||
const shortId = id ? `kb-${id.slice(0, 8)}` : ''
|
||||
|
||||
return (
|
||||
<Link href={href} prefetch={true} className='h-full'>
|
||||
<div className='group flex h-full cursor-pointer flex-col gap-[12px] rounded-[4px] bg-[var(--surface-elevated)] px-[8px] py-[6px] transition-colors hover:bg-[var(--surface-5)]'>
|
||||
<div className='flex items-center justify-between gap-[8px]'>
|
||||
<h3 className='min-w-0 flex-1 truncate text-[14px] text-[var(--text-primary)]'>
|
||||
{title}
|
||||
</h3>
|
||||
{shortId && <Badge className='flex-shrink-0 rounded-[4px] text-[12px]'>{shortId}</Badge>}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-1 flex-col gap-[8px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='flex items-center gap-[6px] text-[12px] text-[var(--text-tertiary)]'>
|
||||
<DocumentAttachment className='h-[12px] w-[12px]' />
|
||||
{docCount} {docCount === 1 ? 'doc' : 'docs'}
|
||||
</span>
|
||||
{updatedAt && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='text-[12px] text-[var(--text-muted)]'>
|
||||
last updated: {formatRelativeTime(updatedAt)}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{formatAbsoluteDate(updatedAt)}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='h-0 w-full border-[var(--divider)] border-t' />
|
||||
|
||||
<p className='line-clamp-2 h-[36px] text-[12px] text-[var(--text-tertiary)] leading-[18px]'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Check, Copy, LibraryBig } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('BaseOverviewComponent')
|
||||
|
||||
interface BaseOverviewProps {
|
||||
id?: string
|
||||
title: string
|
||||
docCount: number
|
||||
description: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return 'just now'
|
||||
}
|
||||
if (diffInSeconds < 3600) {
|
||||
const minutes = Math.floor(diffInSeconds / 60)
|
||||
return `${minutes}m ago`
|
||||
}
|
||||
if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600)
|
||||
return `${hours}h ago`
|
||||
}
|
||||
if (diffInSeconds < 604800) {
|
||||
const days = Math.floor(diffInSeconds / 86400)
|
||||
return `${days}d ago`
|
||||
}
|
||||
if (diffInSeconds < 2592000) {
|
||||
const weeks = Math.floor(diffInSeconds / 604800)
|
||||
return `${weeks}w ago`
|
||||
}
|
||||
if (diffInSeconds < 31536000) {
|
||||
const months = Math.floor(diffInSeconds / 2592000)
|
||||
return `${months}mo ago`
|
||||
}
|
||||
const years = Math.floor(diffInSeconds / 31536000)
|
||||
return `${years}y ago`
|
||||
}
|
||||
|
||||
function formatAbsoluteDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function BaseOverview({
|
||||
id,
|
||||
title,
|
||||
docCount,
|
||||
description,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
}: BaseOverviewProps) {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const params = useParams()
|
||||
const workspaceId = params?.workspaceId as string
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
kbName: title,
|
||||
})
|
||||
const href = `/workspace/${workspaceId}/knowledge/${id || title.toLowerCase().replace(/\s+/g, '-')}?${searchParams.toString()}`
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (id) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(id)
|
||||
setIsCopied(true)
|
||||
setTimeout(() => setIsCopied(false), 2000)
|
||||
} catch (err) {
|
||||
logger.error('Failed to copy ID:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} prefetch={true}>
|
||||
<div className='group flex cursor-pointer flex-col gap-3 rounded-md border bg-background p-4 transition-colors hover:bg-accent/50'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<LibraryBig className='h-4 w-4 flex-shrink-0 text-muted-foreground' />
|
||||
<h3 className='truncate font-medium text-sm leading-tight'>{title}</h3>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex items-center gap-2 text-muted-foreground text-xs'>
|
||||
<span>
|
||||
{docCount} {docCount === 1 ? 'doc' : 'docs'}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='truncate font-mono'>{id?.slice(0, 8)}</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className='flex h-4 w-4 items-center justify-center rounded text-gray-500 hover:bg-gray-100 hover:text-gray-700'
|
||||
>
|
||||
{isCopied ? <Check className='h-3 w-3' /> : <Copy className='h-3 w-3' />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamps */}
|
||||
{(createdAt || updatedAt) && (
|
||||
<div className='flex items-center gap-2 text-muted-foreground text-xs'>
|
||||
{updatedAt && (
|
||||
<span title={`Last updated: ${formatAbsoluteDate(updatedAt)}`}>
|
||||
Updated {formatRelativeTime(updatedAt)}
|
||||
</span>
|
||||
)}
|
||||
{updatedAt && createdAt && <span>•</span>}
|
||||
{createdAt && (
|
||||
<span title={`Created: ${formatAbsoluteDate(createdAt)}`}>
|
||||
Created {formatRelativeTime(createdAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className='line-clamp-2 overflow-hidden text-muted-foreground text-xs'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { AlertCircle, Loader2, RotateCcw, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
|
||||
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
|
||||
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
|
||||
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
|
||||
|
||||
const logger = createLogger('CreateBaseModal')
|
||||
|
||||
interface FileWithPreview extends File {
|
||||
preview: string
|
||||
}
|
||||
|
||||
interface CreateBaseModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onKnowledgeBaseCreated?: (knowledgeBase: KnowledgeBaseData) => void
|
||||
}
|
||||
|
||||
const FormSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name is required')
|
||||
.max(100, 'Name must be less than 100 characters')
|
||||
.refine((value) => value.trim().length > 0, 'Name cannot be empty'),
|
||||
description: z.string().max(500, 'Description must be less than 500 characters').optional(),
|
||||
minChunkSize: z
|
||||
.number()
|
||||
.min(1, 'Min chunk size must be at least 1')
|
||||
.max(2000, 'Min chunk size must be less than 2000'),
|
||||
maxChunkSize: z
|
||||
.number()
|
||||
.min(100, 'Max chunk size must be at least 100')
|
||||
.max(4000, 'Max chunk size must be less than 4000'),
|
||||
overlapSize: z
|
||||
.number()
|
||||
.min(0, 'Overlap size must be non-negative')
|
||||
.max(500, 'Overlap size must be less than 500'),
|
||||
})
|
||||
.refine((data) => data.minChunkSize < data.maxChunkSize, {
|
||||
message: 'Min chunk size must be less than max chunk size',
|
||||
path: ['minChunkSize'],
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof FormSchema>
|
||||
|
||||
interface SubmitStatus {
|
||||
type: 'success' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
export function CreateBaseModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onKnowledgeBaseCreated,
|
||||
}: CreateBaseModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitStatus, setSubmitStatus] = useState<SubmitStatus | null>(null)
|
||||
const [files, setFiles] = useState<FileWithPreview[]>([])
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [dragCounter, setDragCounter] = useState(0)
|
||||
const [retryingIndexes, setRetryingIndexes] = useState<Set<number>>(new Set())
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { uploadFiles, isUploading, uploadProgress, clearError } = useKnowledgeUpload({
|
||||
workspaceId,
|
||||
onUploadComplete: (uploadedFiles) => {
|
||||
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleClose = (open: boolean) => {
|
||||
if (!open) {
|
||||
clearError()
|
||||
}
|
||||
onOpenChange(open)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
files.forEach((file) => {
|
||||
if (file.preview) {
|
||||
URL.revokeObjectURL(file.preview)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [files])
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
minChunkSize: 1,
|
||||
maxChunkSize: 1024,
|
||||
overlapSize: 200,
|
||||
},
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
|
||||
const nameValue = watch('name')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSubmitStatus(null)
|
||||
setFileError(null)
|
||||
setFiles([])
|
||||
setIsDragging(false)
|
||||
setDragCounter(0)
|
||||
setRetryingIndexes(new Set())
|
||||
reset({
|
||||
name: '',
|
||||
description: '',
|
||||
minChunkSize: 1,
|
||||
maxChunkSize: 1024,
|
||||
overlapSize: 200,
|
||||
})
|
||||
}
|
||||
}, [open, reset])
|
||||
|
||||
const processFiles = async (fileList: FileList | File[]) => {
|
||||
setFileError(null)
|
||||
|
||||
if (!fileList || fileList.length === 0) return
|
||||
|
||||
try {
|
||||
const newFiles: FileWithPreview[] = []
|
||||
let hasError = false
|
||||
|
||||
for (const file of Array.from(fileList)) {
|
||||
const validationError = validateKnowledgeBaseFile(file)
|
||||
if (validationError) {
|
||||
setFileError(validationError)
|
||||
hasError = true
|
||||
continue
|
||||
}
|
||||
|
||||
const fileWithPreview = Object.assign(file, {
|
||||
preview: URL.createObjectURL(file),
|
||||
}) as FileWithPreview
|
||||
|
||||
newFiles.push(fileWithPreview)
|
||||
}
|
||||
|
||||
if (!hasError && newFiles.length > 0) {
|
||||
setFiles((prev) => [...prev, ...newFiles])
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing files:', error)
|
||||
setFileError('An error occurred while processing files. Please try again.')
|
||||
} finally {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
await processFiles(e.target.files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragCounter((prev) => {
|
||||
const newCount = prev + 1
|
||||
if (newCount === 1) {
|
||||
setIsDragging(true)
|
||||
}
|
||||
return newCount
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragCounter((prev) => {
|
||||
const newCount = prev - 1
|
||||
if (newCount === 0) {
|
||||
setIsDragging(false)
|
||||
}
|
||||
return newCount
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
setDragCounter(0)
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
await processFiles(e.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => {
|
||||
URL.revokeObjectURL(prev[index].preview)
|
||||
return prev.filter((_, i) => i !== index)
|
||||
})
|
||||
}
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
setIsSubmitting(true)
|
||||
setSubmitStatus(null)
|
||||
|
||||
try {
|
||||
const knowledgeBasePayload = {
|
||||
name: data.name,
|
||||
description: data.description || undefined,
|
||||
workspaceId: workspaceId,
|
||||
chunkingConfig: {
|
||||
maxSize: data.maxChunkSize,
|
||||
minSize: data.minChunkSize,
|
||||
overlap: data.overlapSize,
|
||||
},
|
||||
}
|
||||
|
||||
const response = await fetch('/api/knowledge', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(knowledgeBasePayload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to create knowledge base')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to create knowledge base')
|
||||
}
|
||||
|
||||
const newKnowledgeBase = result.data
|
||||
|
||||
if (files.length > 0) {
|
||||
newKnowledgeBase.docCount = files.length
|
||||
|
||||
if (onKnowledgeBaseCreated) {
|
||||
onKnowledgeBaseCreated(newKnowledgeBase)
|
||||
}
|
||||
|
||||
const uploadedFiles = await uploadFiles(files, newKnowledgeBase.id, {
|
||||
chunkSize: data.maxChunkSize,
|
||||
minCharactersPerChunk: data.minChunkSize,
|
||||
chunkOverlap: data.overlapSize,
|
||||
recipe: 'default',
|
||||
})
|
||||
|
||||
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
|
||||
logger.info(`Started processing ${uploadedFiles.length} documents in the background`)
|
||||
} else {
|
||||
if (onKnowledgeBaseCreated) {
|
||||
onKnowledgeBaseCreated(newKnowledgeBase)
|
||||
}
|
||||
}
|
||||
|
||||
files.forEach((file) => URL.revokeObjectURL(file.preview))
|
||||
setFiles([])
|
||||
|
||||
handleClose(false)
|
||||
} catch (error) {
|
||||
logger.error('Error creating knowledge base:', error)
|
||||
setSubmitStatus({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : 'An unknown error occurred',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>Create Knowledge Base</ModalHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
|
||||
<ModalBody className='!pb-[16px]'>
|
||||
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='space-y-[12px]'>
|
||||
{submitStatus && submitStatus.type === 'error' && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{submitStatus.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='name'>Name</Label>
|
||||
<Input
|
||||
id='name'
|
||||
placeholder='Enter knowledge base name'
|
||||
{...register('name')}
|
||||
className={cn(errors.name && 'border-[var(--text-error)]')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='description'>Description</Label>
|
||||
<Textarea
|
||||
id='description'
|
||||
placeholder='Describe this knowledge base (optional)'
|
||||
rows={3}
|
||||
{...register('description')}
|
||||
className={cn(errors.description && 'border-[var(--text-error)]')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-[12px] rounded-[6px] bg-[var(--surface-6)] px-[12px] py-[14px]'>
|
||||
<div className='grid grid-cols-2 gap-[12px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='minChunkSize'>Min Chunk Size</Label>
|
||||
<Input
|
||||
id='minChunkSize'
|
||||
placeholder='1'
|
||||
{...register('minChunkSize', { valueAsNumber: true })}
|
||||
className={cn(errors.minChunkSize && 'border-[var(--text-error)]')}
|
||||
autoComplete='off'
|
||||
data-form-type='other'
|
||||
name='min-chunk-size'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='maxChunkSize'>Max Chunk Size</Label>
|
||||
<Input
|
||||
id='maxChunkSize'
|
||||
placeholder='1024'
|
||||
{...register('maxChunkSize', { valueAsNumber: true })}
|
||||
className={cn(errors.maxChunkSize && 'border-[var(--text-error)]')}
|
||||
autoComplete='off'
|
||||
data-form-type='other'
|
||||
name='max-chunk-size'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='overlapSize'>Overlap Size</Label>
|
||||
<Input
|
||||
id='overlapSize'
|
||||
placeholder='200'
|
||||
{...register('overlapSize', { valueAsNumber: true })}
|
||||
className={cn(errors.overlapSize && 'border-[var(--text-error)]')}
|
||||
autoComplete='off'
|
||||
data-form-type='other'
|
||||
name='overlap-size'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label>Upload Documents</Label>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
'!bg-[var(--surface-1)] hover:!bg-[var(--surface-4)] w-full justify-center border border-[var(--c-575757)] border-dashed py-[10px]',
|
||||
isDragging && 'border-[var(--brand-primary-hex)]'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept={ACCEPT_ATTRIBUTE}
|
||||
onChange={handleFileChange}
|
||||
className='hidden'
|
||||
multiple
|
||||
/>
|
||||
<div className='flex flex-col gap-[2px] text-center'>
|
||||
<span className='text-[var(--text-primary)]'>
|
||||
{isDragging ? 'Drop files here' : 'Drop files here or click to browse'}
|
||||
</span>
|
||||
<span className='text-[11px] text-[var(--text-tertiary)]'>
|
||||
PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML (max 100MB each)
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className='space-y-2'>
|
||||
<Label>Selected Files</Label>
|
||||
<div className='space-y-2'>
|
||||
{files.map((file, index) => {
|
||||
const fileStatus = uploadProgress.fileStatuses?.[index]
|
||||
const isFailed = fileStatus?.status === 'failed'
|
||||
const isRetrying = retryingIndexes.has(index)
|
||||
const isProcessing = fileStatus?.status === 'uploading' || isRetrying
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className='flex items-center gap-2 rounded-[4px] border p-[8px]'
|
||||
>
|
||||
{isFailed && !isRetrying && (
|
||||
<AlertCircle className='h-4 w-4 flex-shrink-0 text-[var(--text-error)]' />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-[12px]',
|
||||
isFailed && !isRetrying && 'text-[var(--text-error)]'
|
||||
)}
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className='flex-shrink-0 text-[11px] text-[var(--text-muted)]'>
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||
{isFailed && !isRetrying && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
onClick={() => {
|
||||
setRetryingIndexes((prev) => new Set(prev).add(index))
|
||||
removeFile(index)
|
||||
}}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<RotateCcw className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
)}
|
||||
{isProcessing ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin text-[var(--text-muted)]' />
|
||||
) : (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='h-4 w-4 p-0'
|
||||
onClick={() => removeFile(index)}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<X className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileError && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{fileError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={() => handleClose(false)}
|
||||
type='button'
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='primary' type='submit' disabled={isSubmitting || !nameValue?.trim()}>
|
||||
{isSubmitting
|
||||
? isUploading
|
||||
? uploadProgress.stage === 'uploading'
|
||||
? `Uploading ${uploadProgress.filesCompleted}/${uploadProgress.totalFiles}...`
|
||||
: uploadProgress.stage === 'processing'
|
||||
? 'Processing...'
|
||||
: 'Creating...'
|
||||
: 'Creating...'
|
||||
: 'Create'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,650 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { AlertCircle, Check, Loader2, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { Button, Input, Label, Textarea } from '@/components/emcn'
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@/components/emcn/components/modal/modal'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
|
||||
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
|
||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
|
||||
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
|
||||
|
||||
const logger = createLogger('CreateModal')
|
||||
|
||||
interface FileWithPreview extends File {
|
||||
preview: string
|
||||
}
|
||||
|
||||
interface CreateModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onKnowledgeBaseCreated?: (knowledgeBase: KnowledgeBaseData) => void
|
||||
}
|
||||
|
||||
const FormSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name is required')
|
||||
.max(100, 'Name must be less than 100 characters')
|
||||
.refine((value) => value.trim().length > 0, 'Name cannot be empty'),
|
||||
description: z.string().max(500, 'Description must be less than 500 characters').optional(),
|
||||
minChunkSize: z
|
||||
.number()
|
||||
.min(1, 'Min chunk size must be at least 1')
|
||||
.max(2000, 'Min chunk size must be less than 2000'),
|
||||
maxChunkSize: z
|
||||
.number()
|
||||
.min(100, 'Max chunk size must be at least 100')
|
||||
.max(4000, 'Max chunk size must be less than 4000'),
|
||||
overlapSize: z
|
||||
.number()
|
||||
.min(0, 'Overlap size must be non-negative')
|
||||
.max(500, 'Overlap size must be less than 500'),
|
||||
})
|
||||
.refine((data) => data.minChunkSize < data.maxChunkSize, {
|
||||
message: 'Min chunk size must be less than max chunk size',
|
||||
path: ['minChunkSize'],
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof FormSchema>
|
||||
|
||||
interface SubmitStatus {
|
||||
type: 'success' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: CreateModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitStatus, setSubmitStatus] = useState<SubmitStatus | null>(null)
|
||||
const [files, setFiles] = useState<FileWithPreview[]>([])
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [dragCounter, setDragCounter] = useState(0) // Track drag events to handle nested elements
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const dropZoneRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { uploadFiles, isUploading, uploadProgress, uploadError, clearError } = useKnowledgeUpload({
|
||||
workspaceId,
|
||||
onUploadComplete: (uploadedFiles) => {
|
||||
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleClose = (open: boolean) => {
|
||||
if (!open) {
|
||||
clearError()
|
||||
}
|
||||
onOpenChange(open)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
files.forEach((file) => {
|
||||
if (file.preview) {
|
||||
URL.revokeObjectURL(file.preview)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [files])
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
minChunkSize: 1,
|
||||
maxChunkSize: 1024,
|
||||
overlapSize: 200,
|
||||
},
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
|
||||
const nameValue = watch('name')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSubmitStatus(null)
|
||||
setFileError(null)
|
||||
setFiles([])
|
||||
setIsDragging(false)
|
||||
setDragCounter(0)
|
||||
reset({
|
||||
name: '',
|
||||
description: '',
|
||||
minChunkSize: 1,
|
||||
maxChunkSize: 1024,
|
||||
overlapSize: 200,
|
||||
})
|
||||
}
|
||||
}, [open, reset])
|
||||
|
||||
const processFiles = async (fileList: FileList | File[]) => {
|
||||
setFileError(null)
|
||||
|
||||
if (!fileList || fileList.length === 0) return
|
||||
|
||||
try {
|
||||
const newFiles: FileWithPreview[] = []
|
||||
let hasError = false
|
||||
|
||||
for (const file of Array.from(fileList)) {
|
||||
const validationError = validateKnowledgeBaseFile(file)
|
||||
if (validationError) {
|
||||
setFileError(validationError)
|
||||
hasError = true
|
||||
continue
|
||||
}
|
||||
|
||||
const fileWithPreview = Object.assign(file, {
|
||||
preview: URL.createObjectURL(file),
|
||||
}) as FileWithPreview
|
||||
|
||||
newFiles.push(fileWithPreview)
|
||||
}
|
||||
|
||||
if (!hasError && newFiles.length > 0) {
|
||||
setFiles((prev) => [...prev, ...newFiles])
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing files:', error)
|
||||
setFileError('An error occurred while processing files. Please try again.')
|
||||
} finally {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
await processFiles(e.target.files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragCounter((prev) => {
|
||||
const newCount = prev + 1
|
||||
if (newCount === 1) {
|
||||
setIsDragging(true)
|
||||
}
|
||||
return newCount
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragCounter((prev) => {
|
||||
const newCount = prev - 1
|
||||
if (newCount === 0) {
|
||||
setIsDragging(false)
|
||||
}
|
||||
return newCount
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
setDragCounter(0)
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
await processFiles(e.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => {
|
||||
URL.revokeObjectURL(prev[index].preview)
|
||||
return prev.filter((_, i) => i !== index)
|
||||
})
|
||||
}
|
||||
|
||||
const getFileIcon = (mimeType: string, filename: string) => {
|
||||
const IconComponent = getDocumentIcon(mimeType, filename)
|
||||
return <IconComponent className='h-10 w-8' />
|
||||
}
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
setIsSubmitting(true)
|
||||
setSubmitStatus(null)
|
||||
|
||||
try {
|
||||
const knowledgeBasePayload = {
|
||||
name: data.name,
|
||||
description: data.description || undefined,
|
||||
workspaceId: workspaceId,
|
||||
chunkingConfig: {
|
||||
maxSize: data.maxChunkSize,
|
||||
minSize: data.minChunkSize,
|
||||
overlap: data.overlapSize,
|
||||
},
|
||||
}
|
||||
|
||||
const response = await fetch('/api/knowledge', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(knowledgeBasePayload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to create knowledge base')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to create knowledge base')
|
||||
}
|
||||
|
||||
const newKnowledgeBase = result.data
|
||||
|
||||
if (files.length > 0) {
|
||||
newKnowledgeBase.docCount = files.length
|
||||
|
||||
if (onKnowledgeBaseCreated) {
|
||||
onKnowledgeBaseCreated(newKnowledgeBase)
|
||||
}
|
||||
|
||||
const uploadedFiles = await uploadFiles(files, newKnowledgeBase.id, {
|
||||
chunkSize: data.maxChunkSize,
|
||||
minCharactersPerChunk: data.minChunkSize,
|
||||
chunkOverlap: data.overlapSize,
|
||||
recipe: 'default',
|
||||
})
|
||||
|
||||
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
|
||||
logger.info(`Started processing ${uploadedFiles.length} documents in the background`)
|
||||
} else {
|
||||
if (onKnowledgeBaseCreated) {
|
||||
onKnowledgeBaseCreated(newKnowledgeBase)
|
||||
}
|
||||
}
|
||||
|
||||
files.forEach((file) => URL.revokeObjectURL(file.preview))
|
||||
setFiles([])
|
||||
|
||||
handleClose(false)
|
||||
} catch (error) {
|
||||
logger.error('Error creating knowledge base:', error)
|
||||
setSubmitStatus({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : 'An unknown error occurred',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent className='h-[78vh] max-h-[95vh] sm:max-w-[750px]'>
|
||||
<ModalHeader>Create Knowledge Base</ModalHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
|
||||
<ModalBody>
|
||||
<div ref={scrollContainerRef} className='space-y-[12px]'>
|
||||
{/* Show upload error first, then submit error only if no upload error */}
|
||||
{uploadError && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Upload Error</AlertTitle>
|
||||
<AlertDescription>{uploadError.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{submitStatus && submitStatus.type === 'error' && !uploadError && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{submitStatus.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Form Fields Section */}
|
||||
<div className='space-y-[8px]'>
|
||||
<Label
|
||||
htmlFor='name'
|
||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
||||
>
|
||||
Name *
|
||||
</Label>
|
||||
<Input
|
||||
id='name'
|
||||
placeholder='Enter knowledge base name'
|
||||
{...register('name')}
|
||||
className={errors.name ? 'border-[var(--text-error)]' : ''}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className='mt-1 text-[var(--text-error)] text-sm'>{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-[8px]'>
|
||||
<Label
|
||||
htmlFor='description'
|
||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
||||
>
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
id='description'
|
||||
placeholder='Describe what this knowledge base contains (optional)'
|
||||
rows={3}
|
||||
{...register('description')}
|
||||
className={errors.description ? 'border-[var(--text-error)]' : ''}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className='mt-1 text-[var(--text-error)] text-sm'>
|
||||
{errors.description.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chunk Configuration Section */}
|
||||
<div className='space-y-[12px] rounded-lg border p-5'>
|
||||
<h3 className='font-medium text-[var(--text-primary)] text-sm'>
|
||||
Chunking Configuration
|
||||
</h3>
|
||||
|
||||
{/* Min and Max Chunk Size Row */}
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='space-y-[8px]'>
|
||||
<Label
|
||||
htmlFor='minChunkSize'
|
||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
||||
>
|
||||
Min Chunk Size
|
||||
</Label>
|
||||
<Input
|
||||
id='minChunkSize'
|
||||
type='number'
|
||||
placeholder='1'
|
||||
{...register('minChunkSize', { valueAsNumber: true })}
|
||||
className={errors.minChunkSize ? 'border-[var(--text-error)]' : ''}
|
||||
autoComplete='off'
|
||||
data-form-type='other'
|
||||
name='min-chunk-size'
|
||||
/>
|
||||
{errors.minChunkSize && (
|
||||
<p className='mt-1 text-[var(--text-error)] text-xs'>
|
||||
{errors.minChunkSize.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-[8px]'>
|
||||
<Label
|
||||
htmlFor='maxChunkSize'
|
||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
||||
>
|
||||
Max Chunk Size
|
||||
</Label>
|
||||
<Input
|
||||
id='maxChunkSize'
|
||||
type='number'
|
||||
placeholder='1024'
|
||||
{...register('maxChunkSize', { valueAsNumber: true })}
|
||||
className={errors.maxChunkSize ? 'border-[var(--text-error)]' : ''}
|
||||
autoComplete='off'
|
||||
data-form-type='other'
|
||||
name='max-chunk-size'
|
||||
/>
|
||||
{errors.maxChunkSize && (
|
||||
<p className='mt-1 text-[var(--text-error)] text-xs'>
|
||||
{errors.maxChunkSize.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overlap Size */}
|
||||
<div className='space-y-[8px]'>
|
||||
<Label
|
||||
htmlFor='overlapSize'
|
||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
||||
>
|
||||
Overlap Size
|
||||
</Label>
|
||||
<Input
|
||||
id='overlapSize'
|
||||
type='number'
|
||||
placeholder='200'
|
||||
{...register('overlapSize', { valueAsNumber: true })}
|
||||
className={errors.overlapSize ? 'border-[var(--text-error)]' : ''}
|
||||
autoComplete='off'
|
||||
data-form-type='other'
|
||||
name='overlap-size'
|
||||
/>
|
||||
{errors.overlapSize && (
|
||||
<p className='mt-1 text-[var(--text-error)] text-xs'>
|
||||
{errors.overlapSize.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className='text-[var(--text-tertiary)] text-xs'>
|
||||
Configure how documents are split into chunks for processing. Smaller chunks
|
||||
provide more precise retrieval but may lose context.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* File Upload Section */}
|
||||
<div className='space-y-[12px]'>
|
||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Upload Documents
|
||||
</Label>
|
||||
{files.length === 0 ? (
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`relative flex cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-dashed py-8 text-center transition-all duration-200 ${
|
||||
isDragging
|
||||
? 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)]/5'
|
||||
: 'border-[var(--c-575757)] hover:border-[var(--text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept={ACCEPT_ATTRIBUTE}
|
||||
onChange={handleFileChange}
|
||||
className='hidden'
|
||||
multiple
|
||||
/>
|
||||
<div className='flex flex-col items-center gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<p
|
||||
className={`font-medium text-[var(--text-primary)] text-sm transition-colors duration-200 ${
|
||||
isDragging ? 'text-[var(--brand-primary-hex)]' : ''
|
||||
}`}
|
||||
>
|
||||
{isDragging ? 'Drop files here!' : 'Drop files here or click to browse'}
|
||||
</p>
|
||||
<p className='text-[var(--text-tertiary)] text-xs'>
|
||||
Supports PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSON,
|
||||
YAML, YML (max 100MB each)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
{/* Compact drop area at top of file list */}
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`cursor-pointer rounded-md border border-dashed p-3 text-center transition-all duration-200 ${
|
||||
isDragging
|
||||
? 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)]/5'
|
||||
: 'border-[var(--c-575757)] hover:border-[var(--text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept={ACCEPT_ATTRIBUTE}
|
||||
onChange={handleFileChange}
|
||||
className='hidden'
|
||||
multiple
|
||||
/>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<div>
|
||||
<p
|
||||
className={`font-medium text-[var(--text-primary)] text-sm transition-colors duration-200 ${
|
||||
isDragging ? 'text-[var(--brand-primary-hex)]' : ''
|
||||
}`}
|
||||
>
|
||||
{isDragging
|
||||
? 'Drop more files here!'
|
||||
: 'Drop more files or click to browse'}
|
||||
</p>
|
||||
<p className='text-[var(--text-tertiary)] text-xs'>
|
||||
PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML (max 100MB
|
||||
each)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
<div className='space-y-2'>
|
||||
{files.map((file, index) => {
|
||||
const fileStatus = uploadProgress.fileStatuses?.[index]
|
||||
const isCurrentlyUploading = fileStatus?.status === 'uploading'
|
||||
const isCompleted = fileStatus?.status === 'completed'
|
||||
const isFailed = fileStatus?.status === 'failed'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className='flex items-center gap-3 rounded-md border p-3'
|
||||
>
|
||||
{getFileIcon(file.type, file.name)}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{isCurrentlyUploading && (
|
||||
<Loader2 className='h-4 w-4 animate-spin text-[var(--brand-primary-hex)]' />
|
||||
)}
|
||||
{isCompleted && (
|
||||
<Check className='h-4 w-4 text-[var(--text-success)]' />
|
||||
)}
|
||||
{isFailed && <X className='h-4 w-4 text-[var(--text-error)]' />}
|
||||
<p className='truncate font-medium text-[var(--text-primary)] text-sm'>
|
||||
{file.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<p className='text-[var(--text-tertiary)] text-xs'>
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
{isCurrentlyUploading && (
|
||||
<div className='min-w-0 max-w-32 flex-1'>
|
||||
<Progress value={fileStatus?.progress || 0} className='h-1' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isFailed && fileStatus?.error && (
|
||||
<p className='mt-1 text-[var(--text-error)] text-xs'>
|
||||
{fileStatus.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => removeFile(index)}
|
||||
disabled={isUploading}
|
||||
className='h-8 w-8 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{fileError && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{fileError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={() => handleClose(false)}
|
||||
type='button'
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='primary' type='submit' disabled={isSubmitting || !nameValue?.trim()}>
|
||||
{isSubmitting
|
||||
? isUploading
|
||||
? uploadProgress.stage === 'uploading'
|
||||
? `Uploading ${uploadProgress.filesCompleted}/${uploadProgress.totalFiles}...`
|
||||
: uploadProgress.stage === 'processing'
|
||||
? 'Processing...'
|
||||
: 'Creating...'
|
||||
: 'Creating...'
|
||||
: 'Create Knowledge Base'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, Info, Plus, X } from 'lucide-react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui'
|
||||
import { MAX_TAG_SLOTS, type TagSlot } from '@/lib/knowledge/constants'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
|
||||
import { useNextAvailableSlot } from '@/hooks/use-next-available-slot'
|
||||
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/use-tag-definitions'
|
||||
|
||||
const logger = createLogger('DocumentTagEntry')
|
||||
|
||||
export interface DocumentTag {
|
||||
slot: string
|
||||
displayName: string
|
||||
fieldType: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface DocumentTagEntryProps {
|
||||
tags: DocumentTag[]
|
||||
onTagsChange: (newTags: DocumentTag[]) => void
|
||||
disabled?: boolean
|
||||
knowledgeBaseId: string
|
||||
documentId: string | null
|
||||
onSave?: (tagsToSave: DocumentTag[]) => Promise<void>
|
||||
}
|
||||
|
||||
export function DocumentTagEntry({
|
||||
tags,
|
||||
onTagsChange,
|
||||
disabled = false,
|
||||
knowledgeBaseId,
|
||||
documentId,
|
||||
onSave,
|
||||
}: DocumentTagEntryProps) {
|
||||
// Use different hooks based on whether we have a documentId
|
||||
const documentTagHook = useTagDefinitions(knowledgeBaseId, documentId)
|
||||
const kbTagHook = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
const { getNextAvailableSlot: getServerNextSlot } = useNextAvailableSlot(knowledgeBaseId)
|
||||
|
||||
// Use the document-level hook since we have documentId
|
||||
const { saveTagDefinitions } = documentTagHook
|
||||
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook
|
||||
|
||||
// Modal state for tag editing
|
||||
const [editingTagIndex, setEditingTagIndex] = useState<number | null>(null)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editForm, setEditForm] = useState({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
value: '',
|
||||
})
|
||||
|
||||
const handleRemoveTag = async (index: number) => {
|
||||
const updatedTags = tags.filter((_, i) => i !== index)
|
||||
onTagsChange(updatedTags)
|
||||
|
||||
// Persist the changes if onSave is provided
|
||||
if (onSave) {
|
||||
try {
|
||||
await onSave(updatedTags)
|
||||
} catch (error) {
|
||||
// Handle error silently - the UI will show the optimistic update
|
||||
// but the user can retry if needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open modal to edit tag
|
||||
const openTagModal = (index: number) => {
|
||||
const tag = tags[index]
|
||||
setEditingTagIndex(index)
|
||||
setEditForm({
|
||||
displayName: tag.displayName,
|
||||
fieldType: tag.fieldType,
|
||||
value: tag.value,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
// Open modal to create new tag
|
||||
const openNewTagModal = () => {
|
||||
setEditingTagIndex(null)
|
||||
setEditForm({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
value: '',
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
// Save tag from modal
|
||||
const saveTagFromModal = async () => {
|
||||
if (!editForm.displayName.trim() || !editForm.value.trim()) return
|
||||
|
||||
try {
|
||||
let targetSlot: string
|
||||
|
||||
if (editingTagIndex !== null) {
|
||||
// EDIT MODE: Editing existing tag - use existing slot
|
||||
targetSlot = tags[editingTagIndex].slot
|
||||
} else {
|
||||
// CREATE MODE: Check if using existing definition or creating new one
|
||||
const existingDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
|
||||
)
|
||||
|
||||
if (existingDefinition) {
|
||||
// Using existing definition - use its slot
|
||||
targetSlot = existingDefinition.tagSlot
|
||||
} else {
|
||||
// Creating new definition - get next available slot from server
|
||||
const serverSlot = await getServerNextSlot(editForm.fieldType)
|
||||
if (!serverSlot) {
|
||||
throw new Error(`No available slots for new tag of type '${editForm.fieldType}'`)
|
||||
}
|
||||
targetSlot = serverSlot
|
||||
}
|
||||
}
|
||||
|
||||
// Update the tags array
|
||||
if (editingTagIndex !== null) {
|
||||
// Editing existing tag
|
||||
const updatedTags = [...tags]
|
||||
updatedTags[editingTagIndex] = {
|
||||
...updatedTags[editingTagIndex],
|
||||
displayName: editForm.displayName,
|
||||
fieldType: editForm.fieldType,
|
||||
value: editForm.value,
|
||||
}
|
||||
onTagsChange(updatedTags)
|
||||
} else {
|
||||
// Creating new tag
|
||||
const newTag: DocumentTag = {
|
||||
slot: targetSlot,
|
||||
displayName: editForm.displayName,
|
||||
fieldType: editForm.fieldType,
|
||||
value: editForm.value,
|
||||
}
|
||||
const newTags = [...tags, newTag]
|
||||
onTagsChange(newTags)
|
||||
}
|
||||
|
||||
// Handle tag definition creation/update based on edit mode
|
||||
if (editingTagIndex !== null) {
|
||||
// EDIT MODE: Always update existing definition, never create new slots
|
||||
const currentTag = tags[editingTagIndex]
|
||||
const currentDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === currentTag.displayName.toLowerCase()
|
||||
)
|
||||
|
||||
if (currentDefinition) {
|
||||
const updatedDefinition: TagDefinitionInput = {
|
||||
displayName: editForm.displayName,
|
||||
fieldType: currentDefinition.fieldType, // Keep existing field type (can't change in edit mode)
|
||||
tagSlot: currentDefinition.tagSlot, // Keep existing slot
|
||||
_originalDisplayName: currentTag.displayName, // Tell server which definition to update
|
||||
}
|
||||
|
||||
if (saveTagDefinitions) {
|
||||
await saveTagDefinitions([updatedDefinition])
|
||||
} else {
|
||||
throw new Error('Cannot save tag definitions without a document ID')
|
||||
}
|
||||
await refreshTagDefinitions()
|
||||
|
||||
// Update the document tag's display name
|
||||
const updatedTags = [...tags]
|
||||
updatedTags[editingTagIndex] = {
|
||||
...currentTag,
|
||||
displayName: editForm.displayName,
|
||||
fieldType: currentDefinition.fieldType,
|
||||
}
|
||||
onTagsChange(updatedTags)
|
||||
}
|
||||
} else {
|
||||
// CREATE MODE: Adding new tag
|
||||
const existingDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
|
||||
)
|
||||
|
||||
if (!existingDefinition) {
|
||||
// Create new definition
|
||||
const newDefinition: TagDefinitionInput = {
|
||||
displayName: editForm.displayName,
|
||||
fieldType: editForm.fieldType,
|
||||
tagSlot: targetSlot as TagSlot,
|
||||
}
|
||||
|
||||
if (saveTagDefinitions) {
|
||||
await saveTagDefinitions([newDefinition])
|
||||
} else {
|
||||
throw new Error('Cannot save tag definitions without a document ID')
|
||||
}
|
||||
await refreshTagDefinitions()
|
||||
}
|
||||
// If existingDefinition exists, use it (no server update needed)
|
||||
}
|
||||
|
||||
// Save the actual document tags if onSave is provided
|
||||
if (onSave) {
|
||||
const updatedTags =
|
||||
editingTagIndex !== null
|
||||
? tags.map((tag, index) =>
|
||||
index === editingTagIndex
|
||||
? {
|
||||
...tag,
|
||||
displayName: editForm.displayName,
|
||||
fieldType: editForm.fieldType,
|
||||
value: editForm.value,
|
||||
}
|
||||
: tag
|
||||
)
|
||||
: [
|
||||
...tags,
|
||||
{
|
||||
slot: targetSlot,
|
||||
displayName: editForm.displayName,
|
||||
fieldType: editForm.fieldType,
|
||||
value: editForm.value,
|
||||
},
|
||||
]
|
||||
await onSave(updatedTags)
|
||||
}
|
||||
|
||||
setModalOpen(false)
|
||||
} catch (error) {
|
||||
logger.error('Error saving tag:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter available tag definitions based on context
|
||||
const availableDefinitions = kbTagDefinitions.filter((def) => {
|
||||
if (editingTagIndex !== null) {
|
||||
// When editing, exclude only other used tag names (not the current one being edited)
|
||||
return !tags.some(
|
||||
(tag, index) =>
|
||||
index !== editingTagIndex &&
|
||||
tag.displayName.toLowerCase() === def.displayName.toLowerCase()
|
||||
)
|
||||
}
|
||||
// When creating new, exclude all already used tag names
|
||||
return !tags.some((tag) => tag.displayName.toLowerCase() === def.displayName.toLowerCase())
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h3 className='font-medium text-sm'>Document Tags</h3>
|
||||
</div>
|
||||
|
||||
{/* Tags as Badges */}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{tags.map((tag, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant='outline'
|
||||
className='cursor-pointer gap-2 px-3 py-1.5 text-sm transition-colors hover:bg-accent'
|
||||
onClick={() => openTagModal(index)}
|
||||
>
|
||||
<span className='font-medium'>{tag.displayName || 'Unnamed Tag'}</span>
|
||||
{tag.value && (
|
||||
<>
|
||||
<span className='text-muted-foreground'>:</span>
|
||||
<span className='text-muted-foreground'>{tag.value}</span>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemoveTag(index)
|
||||
}}
|
||||
disabled={disabled}
|
||||
className='ml-1 h-4 w-4 p-0 text-muted-foreground hover:text-red-600'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{/* Add Tag Button */}
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={openNewTagModal}
|
||||
disabled={disabled}
|
||||
className='gap-1 border-dashed text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
Add Tag
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tags.length === 0 && (
|
||||
<div className='rounded-md border border-dashed p-4 text-center'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
No tags added yet. Click "Add Tag" to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{kbTagDefinitions.length} of {MAX_TAG_SLOTS} tag slots used
|
||||
</div>
|
||||
|
||||
{/* Tag Edit Modal */}
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className='sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingTagIndex !== null ? 'Edit Tag' : 'Add New Tag'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{/* Tag Name */}
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='tag-name'>Tag Name</Label>
|
||||
{editingTagIndex !== null && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Info className='h-4 w-4 cursor-help text-muted-foreground' />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p className='text-sm'>
|
||||
Changing this tag name will update it for all documents in this knowledge
|
||||
base
|
||||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='tag-name'
|
||||
value={editForm.displayName}
|
||||
onChange={(e) => setEditForm({ ...editForm, displayName: e.target.value })}
|
||||
placeholder='Enter tag name'
|
||||
className='flex-1'
|
||||
/>
|
||||
{editingTagIndex === null && availableDefinitions.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='sm'>
|
||||
<ChevronDown className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
{availableDefinitions.map((def) => (
|
||||
<DropdownMenuItem
|
||||
key={def.id}
|
||||
onClick={() =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
displayName: def.displayName,
|
||||
fieldType: def.fieldType,
|
||||
})
|
||||
}
|
||||
>
|
||||
{def.displayName}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag Type */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='tag-type'>Type</Label>
|
||||
<Select
|
||||
value={editForm.fieldType}
|
||||
onValueChange={(value) => setEditForm({ ...editForm, fieldType: value })}
|
||||
disabled={editingTagIndex !== null} // Disable in edit mode
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='text'>Text</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Tag Value */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='tag-value'>Value</Label>
|
||||
<Input
|
||||
id='tag-value'
|
||||
value={editForm.value}
|
||||
onChange={(e) => setEditForm({ ...editForm, value: e.target.value })}
|
||||
placeholder='Enter tag value'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show warning when at max slots in create mode */}
|
||||
{editingTagIndex === null && kbTagDefinitions.length >= MAX_TAG_SLOTS && (
|
||||
<div className='rounded-md border border-amber-200 bg-amber-50 p-3'>
|
||||
<div className='flex items-center gap-2 text-amber-800 text-sm'>
|
||||
<span className='font-medium'>Maximum tag definitions reached</span>
|
||||
</div>
|
||||
<p className='mt-1 text-amber-700 text-xs'>
|
||||
You can still use existing tag definitions from the dropdown, but cannot create new
|
||||
ones.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex justify-end gap-2 pt-4'>
|
||||
<Button variant='outline' onClick={() => setModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveTagFromModal}
|
||||
disabled={(() => {
|
||||
if (!editForm.displayName.trim()) return true
|
||||
|
||||
// In edit mode, always allow
|
||||
if (editingTagIndex !== null) return false
|
||||
|
||||
// In create mode, check if we're creating a new definition at max slots
|
||||
const existingDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
|
||||
)
|
||||
|
||||
// If using existing definition, allow
|
||||
if (existingDefinition) return false
|
||||
|
||||
// If creating new definition and at max slots, disable
|
||||
return kbTagDefinitions.length >= MAX_TAG_SLOTS
|
||||
})()}
|
||||
>
|
||||
{(() => {
|
||||
if (editingTagIndex !== null) {
|
||||
return 'Save Changes'
|
||||
}
|
||||
|
||||
const existingDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
|
||||
)
|
||||
|
||||
if (existingDefinition) {
|
||||
return 'Use Existing Tag'
|
||||
}
|
||||
if (kbTagDefinitions.length >= MAX_TAG_SLOTS) {
|
||||
return 'Max Tags Reached'
|
||||
}
|
||||
return 'Create New Tag'
|
||||
})()}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { LibraryBig } from 'lucide-react'
|
||||
|
||||
interface EmptyStateCardProps {
|
||||
title: string
|
||||
description: string
|
||||
buttonText: string
|
||||
onClick: () => void
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export function EmptyStateCard({
|
||||
title,
|
||||
description,
|
||||
buttonText,
|
||||
onClick,
|
||||
icon,
|
||||
}: EmptyStateCardProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className='group flex cursor-pointer flex-col gap-3 rounded-md border border-muted-foreground/25 border-dashed bg-background p-4 transition-colors hover:border-muted-foreground/40 hover:bg-accent/50'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
{icon || <LibraryBig className='h-4 w-4 flex-shrink-0 text-muted-foreground' />}
|
||||
<h3 className='truncate font-medium text-sm leading-tight'>{title}</h3>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex items-center gap-2 text-muted-foreground text-xs'>
|
||||
<span>Get started</span>
|
||||
</div>
|
||||
|
||||
<p className='line-clamp-2 overflow-hidden text-muted-foreground text-xs'>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,4 @@
|
||||
export { BaseOverview } from './base-overview/base-overview'
|
||||
export { CreateModal } from './create-modal/create-modal'
|
||||
export { EmptyStateCard } from './empty-state-card/empty-state-card'
|
||||
export { BaseCard, BaseCardSkeleton, BaseCardSkeletonGrid } from './base-card/base-card'
|
||||
export { CreateBaseModal } from './create-base-modal/create-base-modal'
|
||||
export { getDocumentIcon } from './icons/document-icons'
|
||||
export { KnowledgeHeader } from './knowledge-header/knowledge-header'
|
||||
export { PrimaryButton } from './primary-button/primary-button'
|
||||
export { SearchInput } from './search-input/search-input'
|
||||
export { KnowledgeBaseCardSkeletonGrid } from './skeletons/knowledge-base-card-skeleton'
|
||||
export { ChunkTableSkeleton, DocumentTableSkeleton } from './skeletons/table-skeleton'
|
||||
export { type TagData, TagInput } from './tag-input/tag-input'
|
||||
export { WorkspaceSelector } from './workspace-selector/workspace-selector'
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { LibraryBig, MoreHorizontal } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AlertTriangle, ChevronDown, LibraryBig, MoreHorizontal } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { WorkspaceSelector } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/shared'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants'
|
||||
import { useKnowledgeStore } from '@/stores/knowledge/store'
|
||||
|
||||
const logger = createLogger('KnowledgeHeader')
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string
|
||||
@@ -27,7 +37,7 @@ const HEADER_STYLES = {
|
||||
interface KnowledgeHeaderOptions {
|
||||
knowledgeBaseId?: string
|
||||
currentWorkspaceId?: string | null
|
||||
onWorkspaceChange?: (workspaceId: string | null) => void
|
||||
onWorkspaceChange?: (workspaceId: string | null) => void | Promise<void>
|
||||
onDeleteKnowledgeBase?: () => void
|
||||
}
|
||||
|
||||
@@ -36,8 +46,101 @@ interface KnowledgeHeaderProps {
|
||||
options?: KnowledgeHeaderOptions
|
||||
}
|
||||
|
||||
interface Workspace {
|
||||
id: string
|
||||
name: string
|
||||
permissions: 'admin' | 'write' | 'read'
|
||||
}
|
||||
|
||||
export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
|
||||
const { updateKnowledgeBase } = useKnowledgeStore()
|
||||
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false)
|
||||
const [isWorkspacePopoverOpen, setIsWorkspacePopoverOpen] = useState(false)
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
|
||||
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
|
||||
const [isUpdatingWorkspace, setIsUpdatingWorkspace] = useState(false)
|
||||
|
||||
// Fetch available workspaces
|
||||
useEffect(() => {
|
||||
if (!options?.knowledgeBaseId) return
|
||||
|
||||
const fetchWorkspaces = async () => {
|
||||
try {
|
||||
setIsLoadingWorkspaces(true)
|
||||
|
||||
const response = await fetch('/api/workspaces')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch workspaces')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Filter workspaces where user has write/admin permissions
|
||||
const availableWorkspaces = data.workspaces
|
||||
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
|
||||
.map((ws: any) => ({
|
||||
id: ws.id,
|
||||
name: ws.name,
|
||||
permissions: ws.permissions,
|
||||
}))
|
||||
|
||||
setWorkspaces(availableWorkspaces)
|
||||
} catch (err) {
|
||||
logger.error('Error fetching workspaces:', err)
|
||||
} finally {
|
||||
setIsLoadingWorkspaces(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchWorkspaces()
|
||||
}, [options?.knowledgeBaseId])
|
||||
|
||||
const handleWorkspaceChange = async (workspaceId: string | null) => {
|
||||
if (isUpdatingWorkspace || !options?.knowledgeBaseId) return
|
||||
|
||||
try {
|
||||
setIsUpdatingWorkspace(true)
|
||||
setIsWorkspacePopoverOpen(false)
|
||||
|
||||
const response = await fetch(`/api/knowledge/${options.knowledgeBaseId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to update workspace')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
logger.info(
|
||||
`Knowledge base workspace updated: ${options.knowledgeBaseId} -> ${workspaceId}`
|
||||
)
|
||||
|
||||
// Notify parent component of the change to refresh data
|
||||
await options.onWorkspaceChange?.(workspaceId)
|
||||
|
||||
// Update the store after refresh to ensure consistency
|
||||
updateKnowledgeBase(options.knowledgeBaseId, { workspaceId: workspaceId || undefined })
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to update workspace')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error updating workspace:', err)
|
||||
} finally {
|
||||
setIsUpdatingWorkspace(false)
|
||||
}
|
||||
}
|
||||
|
||||
const currentWorkspace = workspaces.find((ws) => ws.id === options?.currentWorkspaceId)
|
||||
const hasWorkspace = !!options?.currentWorkspaceId
|
||||
|
||||
return (
|
||||
<div className={HEADER_STYLES.container}>
|
||||
@@ -69,11 +172,67 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
|
||||
<div className={HEADER_STYLES.actionsContainer}>
|
||||
{/* Workspace Selector */}
|
||||
{options.knowledgeBaseId && (
|
||||
<WorkspaceSelector
|
||||
knowledgeBaseId={options.knowledgeBaseId}
|
||||
currentWorkspaceId={options.currentWorkspaceId || null}
|
||||
onWorkspaceChange={options.onWorkspaceChange}
|
||||
/>
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* Warning icon for unassigned knowledge bases */}
|
||||
{!hasWorkspace && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<AlertTriangle className='h-4 w-4 text-amber-500' />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>Not assigned to workspace</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{/* Workspace selector dropdown */}
|
||||
<Popover open={isWorkspacePopoverOpen} onOpenChange={setIsWorkspacePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={isLoadingWorkspaces || isUpdatingWorkspace}
|
||||
className={filterButtonClass}
|
||||
>
|
||||
<span className='truncate'>
|
||||
{isLoadingWorkspaces
|
||||
? 'Loading...'
|
||||
: isUpdatingWorkspace
|
||||
? 'Updating...'
|
||||
: currentWorkspace?.name || 'No workspace'}
|
||||
</span>
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='end' side='bottom' sideOffset={4}>
|
||||
{/* No workspace option */}
|
||||
<PopoverItem
|
||||
active={!options.currentWorkspaceId}
|
||||
showCheck
|
||||
onClick={() => handleWorkspaceChange(null)}
|
||||
>
|
||||
<span className='text-muted-foreground'>No workspace</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Available workspaces */}
|
||||
{workspaces.map((workspace) => (
|
||||
<PopoverItem
|
||||
key={workspace.id}
|
||||
active={options.currentWorkspaceId === workspace.id}
|
||||
showCheck
|
||||
onClick={() => handleWorkspaceChange(workspace.id)}
|
||||
>
|
||||
{workspace.name}
|
||||
</PopoverItem>
|
||||
))}
|
||||
|
||||
{workspaces.length === 0 && !isLoadingWorkspaces && (
|
||||
<PopoverItem disabled>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
No workspaces with write access
|
||||
</span>
|
||||
</PopoverItem>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions Menu */}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
interface PrimaryButtonProps {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
}
|
||||
|
||||
export function PrimaryButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
className,
|
||||
type = 'button',
|
||||
}: PrimaryButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
variant='primary'
|
||||
className={cn(
|
||||
'flex h-8 items-center gap-1 px-[8px] py-[6px] font-[480] shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
disabled && 'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Search, X } from 'lucide-react'
|
||||
|
||||
interface SearchInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
className = 'max-w-md flex-1',
|
||||
isLoading = false,
|
||||
}: SearchInputProps) {
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<div className='relative flex items-center'>
|
||||
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-[18px] w-[18px] transform text-muted-foreground' />
|
||||
<input
|
||||
type='text'
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className='h-10 w-full rounded-md border bg-background px-9 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
/>
|
||||
{isLoading ? (
|
||||
<div className='-translate-y-1/2 absolute top-1/2 right-3'>
|
||||
<div className='h-[18px] w-[18px] animate-spin rounded-full border-2 border-gray-300 border-t-[var(--brand-primary-hex)]' />
|
||||
</div>
|
||||
) : (
|
||||
value &&
|
||||
!disabled && (
|
||||
<button
|
||||
onClick={() => onChange('')}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 transform text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
<X className='h-[18px] w-[18px]' />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
export function KnowledgeBaseCardSkeleton() {
|
||||
return (
|
||||
<div className='rounded-lg border bg-background p-4'>
|
||||
<div className='flex items-start justify-between'>
|
||||
<div className='flex-1 space-y-3'>
|
||||
{/* Title skeleton */}
|
||||
<div className='h-4 w-3/4 animate-pulse rounded bg-muted' />
|
||||
|
||||
{/* Description skeleton */}
|
||||
<div className='space-y-2'>
|
||||
<div className='h-3 w-full animate-pulse rounded bg-muted' />
|
||||
<div className='h-3 w-2/3 animate-pulse rounded bg-muted' />
|
||||
</div>
|
||||
|
||||
{/* Stats skeleton */}
|
||||
<div className='flex items-center gap-4 pt-2'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='h-3 w-3 animate-pulse rounded bg-muted' />
|
||||
<div className='h-3 w-8 animate-pulse rounded bg-muted' />
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='h-3 w-3 animate-pulse rounded bg-muted' />
|
||||
<div className='h-3 w-12 animate-pulse rounded bg-muted' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function KnowledgeBaseCardSkeletonGrid({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<KnowledgeBaseCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
export function DocumentTableRowSkeleton({ isSidebarCollapsed }: { isSidebarCollapsed: boolean }) {
|
||||
return (
|
||||
<tr className='border-b'>
|
||||
{/* Select column */}
|
||||
<td className='px-4 py-3'>
|
||||
<div className='h-3.5 w-3.5 animate-pulse rounded bg-muted' />
|
||||
</td>
|
||||
|
||||
{/* Name column */}
|
||||
<td className='px-4 py-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='h-6 w-5 animate-pulse rounded bg-muted' />
|
||||
<div className='h-4 w-32 animate-pulse rounded bg-muted' />
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Size column */}
|
||||
<td className='px-4 py-3'>
|
||||
<div className='h-3 w-12 animate-pulse rounded bg-muted' />
|
||||
</td>
|
||||
|
||||
{/* Tokens column */}
|
||||
<td className='px-4 py-3'>
|
||||
<div className='h-3 w-8 animate-pulse rounded bg-muted' />
|
||||
</td>
|
||||
|
||||
{/* Chunks column - hidden on small screens */}
|
||||
<td className='hidden px-4 py-3 lg:table-cell'>
|
||||
<div className='h-3 w-6 animate-pulse rounded bg-muted' />
|
||||
</td>
|
||||
|
||||
{/* Upload Time column */}
|
||||
<td className='px-4 py-3'>
|
||||
<div className='space-y-1'>
|
||||
<div className='h-3 w-16 animate-pulse rounded bg-muted' />
|
||||
<div className='h-3 w-12 animate-pulse rounded bg-muted lg:hidden' />
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Status column */}
|
||||
<td className='px-4 py-3'>
|
||||
<div className='h-6 w-16 animate-pulse rounded-md bg-muted' />
|
||||
</td>
|
||||
|
||||
{/* Actions column */}
|
||||
<td className='px-4 py-3'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='h-8 w-8 animate-pulse rounded bg-muted' />
|
||||
<div className='h-8 w-8 animate-pulse rounded bg-muted' />
|
||||
<div className='h-8 w-8 animate-pulse rounded bg-muted' />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChunkTableRowSkeleton({ isSidebarCollapsed }: { isSidebarCollapsed: boolean }) {
|
||||
return (
|
||||
<tr className='border-b'>
|
||||
{/* Select column */}
|
||||
<td className='px-4 py-3'>
|
||||
<div className='h-3.5 w-3.5 animate-pulse rounded bg-muted' />
|
||||
</td>
|
||||
|
||||
{/* Index column */}
|
||||
<td className='px-4 py-3'>
|
||||
<div className='h-4 w-6 animate-pulse rounded bg-muted' />
|
||||
</td>
|
||||
|
||||
{/* Content column */}
|
||||
<td className='px-4 py-3'>
|
||||
<div className='space-y-2'>
|
||||
<div className='h-4 w-full animate-pulse rounded bg-muted' />
|
||||
<div className='h-4 w-3/4 animate-pulse rounded bg-muted' />
|
||||
<div className='h-4 w-1/2 animate-pulse rounded bg-muted' />
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Tokens column */}
|
||||
<td className='px-4 py-3'>
|
||||
<div className='h-3 w-8 animate-pulse rounded bg-muted' />
|
||||
</td>
|
||||
|
||||
{/* Status column */}
|
||||
<td className='px-4 py-3'>
|
||||
<div className='h-6 w-16 animate-pulse rounded-md bg-muted' />
|
||||
</td>
|
||||
|
||||
{/* Actions column */}
|
||||
<td className='px-4 py-3'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='h-8 w-8 animate-pulse rounded bg-muted' />
|
||||
<div className='h-8 w-8 animate-pulse rounded bg-muted' />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export function DocumentTableSkeleton({
|
||||
isSidebarCollapsed,
|
||||
rowCount = 5,
|
||||
}: {
|
||||
isSidebarCollapsed: boolean
|
||||
rowCount?: number
|
||||
}) {
|
||||
return (
|
||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
||||
{/* Table header - fixed */}
|
||||
<div className='sticky top-0 z-10 overflow-x-auto border-b bg-background'>
|
||||
<table className='w-full min-w-[700px] table-fixed'>
|
||||
<colgroup>
|
||||
<col className='w-[4%]' />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[22%]' : 'w-[24%]'}`} />
|
||||
<col className='w-[8%]' />
|
||||
<col className='w-[8%]' />
|
||||
<col className='hidden w-[8%] lg:table-column' />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[18%]' : 'w-[16%]'}`} />
|
||||
<col className='w-[12%]' />
|
||||
<col className='w-[14%]' />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
||||
<div className='h-3.5 w-3.5 animate-pulse rounded bg-muted' />
|
||||
</th>
|
||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
||||
<span className='text-muted-foreground text-xs leading-none'>Name</span>
|
||||
</th>
|
||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
||||
<span className='text-muted-foreground text-xs leading-none'>Size</span>
|
||||
</th>
|
||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
||||
<span className='text-muted-foreground text-xs leading-none'>Tokens</span>
|
||||
</th>
|
||||
<th className='hidden px-4 pt-2 pb-3 text-left font-medium lg:table-cell'>
|
||||
<span className='text-muted-foreground text-xs leading-none'>Chunks</span>
|
||||
</th>
|
||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
||||
<span className='text-muted-foreground text-xs leading-none'>Uploaded</span>
|
||||
</th>
|
||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
||||
<span className='text-muted-foreground text-xs leading-none'>Status</span>
|
||||
</th>
|
||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
||||
<span className='text-muted-foreground text-xs leading-none'>Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Table body - scrollable */}
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<table className='w-full min-w-[700px] table-fixed'>
|
||||
<colgroup>
|
||||
<col className='w-[4%]' />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[22%]' : 'w-[24%]'}`} />
|
||||
<col className='w-[8%]' />
|
||||
<col className='w-[8%]' />
|
||||
<col className='hidden w-[8%] lg:table-column' />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[18%]' : 'w-[16%]'}`} />
|
||||
<col className='w-[12%]' />
|
||||
<col className='w-[14%]' />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{Array.from({ length: rowCount }).map((_, i) => (
|
||||
<DocumentTableRowSkeleton key={i} isSidebarCollapsed={isSidebarCollapsed} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChunkTableSkeleton({
|
||||
isSidebarCollapsed,
|
||||
rowCount = 5,
|
||||
}: {
|
||||
isSidebarCollapsed: boolean
|
||||
rowCount?: number
|
||||
}) {
|
||||
return (
|
||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
||||
{/* Table header - fixed */}
|
||||
<div className='sticky top-0 z-10 border-b bg-background'>
|
||||
<table className='w-full table-fixed'>
|
||||
<colgroup>
|
||||
<col className='w-[5%]' />
|
||||
<col className='w-[8%]' />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[57%]' : 'w-[55%]'}`} />
|
||||
<col className='w-[10%]' />
|
||||
<col className='w-[10%]' />
|
||||
<col className='w-[12%]' />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
||||
<div className='h-3.5 w-3.5 animate-pulse rounded bg-muted' />
|
||||
</th>
|
||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
||||
<span className='text-muted-foreground text-xs leading-none'>Index</span>
|
||||
</th>
|
||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
||||
<span className='text-muted-foreground text-xs leading-none'>Content</span>
|
||||
</th>
|
||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
||||
<span className='text-muted-foreground text-xs leading-none'>Tokens</span>
|
||||
</th>
|
||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
||||
<span className='text-muted-foreground text-xs leading-none'>Status</span>
|
||||
</th>
|
||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
||||
<span className='text-muted-foreground text-xs leading-none'>Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Table body - scrollable */}
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<table className='w-full table-fixed'>
|
||||
<colgroup>
|
||||
<col className='w-[5%]' />
|
||||
<col className='w-[8%]' />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[57%]' : 'w-[55%]'}`} />
|
||||
<col className='w-[10%]' />
|
||||
<col className='w-[10%]' />
|
||||
<col className='w-[12%]' />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{Array.from({ length: rowCount }).map((_, i) => (
|
||||
<ChunkTableRowSkeleton key={i} isSidebarCollapsed={isSidebarCollapsed} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, Plus, Settings, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { TAG_SLOTS, type TagSlot } from '@/lib/knowledge/constants'
|
||||
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
|
||||
|
||||
export type TagData = {
|
||||
[K in TagSlot]?: string
|
||||
}
|
||||
|
||||
interface TagInputProps {
|
||||
tags: TagData
|
||||
onTagsChange: (tags: TagData) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
knowledgeBaseId?: string | null
|
||||
documentId?: string | null
|
||||
}
|
||||
|
||||
const TAG_LABELS = TAG_SLOTS.map((slot, index) => ({
|
||||
key: slot as keyof TagData,
|
||||
label: `Tag ${index + 1}`,
|
||||
placeholder: 'Enter tag value',
|
||||
}))
|
||||
|
||||
export function TagInput({
|
||||
tags,
|
||||
onTagsChange,
|
||||
disabled = false,
|
||||
className = '',
|
||||
knowledgeBaseId = null,
|
||||
documentId = null,
|
||||
}: TagInputProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showAllTags, setShowAllTags] = useState(false)
|
||||
|
||||
// Use custom tag definitions if available
|
||||
const { getTagLabel } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
|
||||
const handleTagChange = (tagKey: keyof TagData, value: string) => {
|
||||
onTagsChange({
|
||||
...tags,
|
||||
[tagKey]: value.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const clearTag = (tagKey: keyof TagData) => {
|
||||
onTagsChange({
|
||||
...tags,
|
||||
[tagKey]: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const hasAnyTags = Object.values(tags).some((tag) => tag?.trim())
|
||||
|
||||
// Create tag labels using custom definitions or fallback to defaults
|
||||
const tagLabels = TAG_LABELS.map(({ key, placeholder }) => ({
|
||||
key,
|
||||
label: getTagLabel(key),
|
||||
placeholder,
|
||||
}))
|
||||
|
||||
const visibleTags = showAllTags ? tagLabels : tagLabels.slice(0, 2)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='flex h-auto w-full justify-between p-0 hover:bg-transparent'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Settings className='h-4 w-4 text-muted-foreground' />
|
||||
<Label className='cursor-pointer font-medium text-sm'>Advanced Settings</Label>
|
||||
{hasAnyTags && (
|
||||
<span className='rounded-full bg-primary/10 px-2 py-0.5 text-muted-foreground text-xs'>
|
||||
{Object.values(tags).filter((tag) => tag?.trim()).length} tag
|
||||
{Object.values(tags).filter((tag) => tag?.trim()).length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<ChevronDown className='h-4 w-4 text-muted-foreground' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4 text-muted-foreground' />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className='space-y-4 pt-4'>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label className='font-medium text-sm'>Document Tags</Label>
|
||||
{!showAllTags && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setShowAllTags(true)}
|
||||
className='h-auto p-1 text-muted-foreground text-xs hover:text-foreground'
|
||||
>
|
||||
<Plus className='mr-1 h-3 w-3' />
|
||||
More tags
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
|
||||
{visibleTags.map(({ key, label, placeholder }) => (
|
||||
<div key={key} className='space-y-1'>
|
||||
<Label htmlFor={key} className='text-muted-foreground text-xs'>
|
||||
{label}
|
||||
</Label>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id={key}
|
||||
type='text'
|
||||
value={tags[key] || ''}
|
||||
onChange={(e) => handleTagChange(key, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className='pr-8 text-sm'
|
||||
/>
|
||||
{tags[key] && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => clearTag(key)}
|
||||
disabled={disabled}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-1 h-6 w-6 p-0 hover:bg-muted'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showAllTags && (
|
||||
<div className='flex justify-center'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setShowAllTags(false)}
|
||||
className='h-auto p-1 text-muted-foreground text-xs hover:text-foreground'
|
||||
>
|
||||
Show fewer tags
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAnyTags && (
|
||||
<div className='rounded-md bg-muted/50 p-3'>
|
||||
<p className='mb-2 text-muted-foreground text-xs'>Active tags:</p>
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{Object.entries(tags).map(([key, value]) => {
|
||||
if (!value?.trim()) return null
|
||||
const tagLabel = getTagLabel(key)
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className='inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-muted-foreground text-xs'
|
||||
>
|
||||
<span className='font-medium'>{tagLabel}:</span>
|
||||
<span>{value}</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => clearTag(key as keyof TagData)}
|
||||
disabled={disabled}
|
||||
className='h-3 w-3 p-0 hover:bg-primary/20'
|
||||
>
|
||||
<X className='h-2 w-2' />
|
||||
</Button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AlertTriangle, ChevronDown } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/shared'
|
||||
import { useKnowledgeStore } from '@/stores/knowledge/store'
|
||||
|
||||
const logger = createLogger('WorkspaceSelector')
|
||||
|
||||
interface Workspace {
|
||||
id: string
|
||||
name: string
|
||||
permissions: 'admin' | 'write' | 'read'
|
||||
}
|
||||
|
||||
interface WorkspaceSelectorProps {
|
||||
knowledgeBaseId: string
|
||||
currentWorkspaceId: string | null
|
||||
onWorkspaceChange?: (workspaceId: string | null) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function WorkspaceSelector({
|
||||
knowledgeBaseId,
|
||||
currentWorkspaceId,
|
||||
onWorkspaceChange,
|
||||
disabled = false,
|
||||
}: WorkspaceSelectorProps) {
|
||||
const { updateKnowledgeBase } = useKnowledgeStore()
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
|
||||
|
||||
// Fetch available workspaces
|
||||
useEffect(() => {
|
||||
const fetchWorkspaces = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
const response = await fetch('/api/workspaces')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch workspaces')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Filter workspaces where user has write/admin permissions
|
||||
const availableWorkspaces = data.workspaces
|
||||
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
|
||||
.map((ws: any) => ({
|
||||
id: ws.id,
|
||||
name: ws.name,
|
||||
permissions: ws.permissions,
|
||||
}))
|
||||
|
||||
setWorkspaces(availableWorkspaces)
|
||||
} catch (err) {
|
||||
logger.error('Error fetching workspaces:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchWorkspaces()
|
||||
}, [])
|
||||
|
||||
const handleWorkspaceChange = async (workspaceId: string | null) => {
|
||||
if (isUpdating || disabled) return
|
||||
|
||||
try {
|
||||
setIsUpdating(true)
|
||||
setIsPopoverOpen(false)
|
||||
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to update workspace')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`Knowledge base workspace updated: ${knowledgeBaseId} -> ${workspaceId}`)
|
||||
|
||||
// Notify parent component of the change to refresh data
|
||||
await onWorkspaceChange?.(workspaceId)
|
||||
|
||||
// Update the store after refresh to ensure consistency
|
||||
updateKnowledgeBase(knowledgeBaseId, { workspaceId: workspaceId || undefined })
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to update workspace')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error updating workspace:', err)
|
||||
} finally {
|
||||
setIsUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const currentWorkspace = workspaces.find((ws) => ws.id === currentWorkspaceId)
|
||||
const hasWorkspace = !!currentWorkspaceId
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* Warning icon for unassigned knowledge bases */}
|
||||
{!hasWorkspace && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<AlertTriangle className='h-4 w-4 text-amber-500' />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>Not assigned to workspace</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{/* Workspace selector dropdown */}
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={disabled || isLoading || isUpdating}
|
||||
className={filterButtonClass}
|
||||
>
|
||||
<span className='truncate'>
|
||||
{isLoading
|
||||
? 'Loading...'
|
||||
: isUpdating
|
||||
? 'Updating...'
|
||||
: currentWorkspace?.name || 'No workspace'}
|
||||
</span>
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='end' side='bottom' sideOffset={4}>
|
||||
{/* No workspace option */}
|
||||
<PopoverItem
|
||||
active={!currentWorkspaceId}
|
||||
showCheck
|
||||
onClick={() => handleWorkspaceChange(null)}
|
||||
>
|
||||
<span className='text-muted-foreground'>No workspace</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Available workspaces */}
|
||||
{workspaces.map((workspace) => (
|
||||
<PopoverItem
|
||||
key={workspace.id}
|
||||
active={currentWorkspaceId === workspace.id}
|
||||
showCheck
|
||||
onClick={() => handleWorkspaceChange(workspace.id)}
|
||||
>
|
||||
{workspace.name}
|
||||
</PopoverItem>
|
||||
))}
|
||||
|
||||
{workspaces.length === 0 && !isLoading && (
|
||||
<PopoverItem disabled>
|
||||
<span className='text-muted-foreground text-xs'>No workspaces with write access</span>
|
||||
</PopoverItem>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('KnowledgeUpload')
|
||||
@@ -84,15 +84,18 @@ class ProcessingError extends KnowledgeUploadError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration constants for file upload operations
|
||||
*/
|
||||
const UPLOAD_CONFIG = {
|
||||
MAX_PARALLEL_UPLOADS: 3, // Prevent client saturation – mirrors guidance on limiting simultaneous transfers (@Web)
|
||||
MAX_PARALLEL_UPLOADS: 3,
|
||||
MAX_RETRIES: 3,
|
||||
RETRY_DELAY_MS: 2000,
|
||||
RETRY_BACKOFF: 2,
|
||||
CHUNK_SIZE: 8 * 1024 * 1024, // 8MB keeps us well above S3 minimum part size while reducing part count (@Web)
|
||||
CHUNK_SIZE: 8 * 1024 * 1024,
|
||||
DIRECT_UPLOAD_THRESHOLD: 4 * 1024 * 1024,
|
||||
LARGE_FILE_THRESHOLD: 50 * 1024 * 1024,
|
||||
BASE_TIMEOUT_MS: 2 * 60 * 1000, // baseline per transfer window per large-file guidance (@Web)
|
||||
BASE_TIMEOUT_MS: 2 * 60 * 1000,
|
||||
TIMEOUT_PER_MB_MS: 1500,
|
||||
MAX_TIMEOUT_MS: 10 * 60 * 1000,
|
||||
MULTIPART_PART_CONCURRENCY: 3,
|
||||
@@ -100,28 +103,49 @@ const UPLOAD_CONFIG = {
|
||||
BATCH_REQUEST_SIZE: 50,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Calculates the upload timeout based on file size
|
||||
*/
|
||||
const calculateUploadTimeoutMs = (fileSize: number) => {
|
||||
const sizeInMb = fileSize / (1024 * 1024)
|
||||
const dynamicBudget = UPLOAD_CONFIG.BASE_TIMEOUT_MS + sizeInMb * UPLOAD_CONFIG.TIMEOUT_PER_MB_MS
|
||||
return Math.min(dynamicBudget, UPLOAD_CONFIG.MAX_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delays execution for the specified duration
|
||||
*/
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
/**
|
||||
* Gets high resolution timestamp for performance measurements
|
||||
*/
|
||||
const getHighResTime = () =>
|
||||
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now()
|
||||
|
||||
/**
|
||||
* Formats bytes to megabytes with 2 decimal places
|
||||
*/
|
||||
const formatMegabytes = (bytes: number) => Number((bytes / (1024 * 1024)).toFixed(2))
|
||||
|
||||
/**
|
||||
* Calculates throughput in Mbps
|
||||
*/
|
||||
const calculateThroughputMbps = (bytes: number, durationMs: number) => {
|
||||
if (!bytes || !durationMs) return 0
|
||||
return Number((((bytes * 8) / durationMs) * 0.001).toFixed(2))
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats duration from milliseconds to seconds
|
||||
*/
|
||||
const formatDurationSeconds = (durationMs: number) => Number((durationMs / 1000).toFixed(2))
|
||||
|
||||
/**
|
||||
* Runs async operations with concurrency limit
|
||||
*/
|
||||
const runWithConcurrency = async <T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
@@ -156,14 +180,26 @@ const runWithConcurrency = async <T, R>(
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the error name from an unknown error object
|
||||
*/
|
||||
const getErrorName = (error: unknown) =>
|
||||
typeof error === 'object' && error !== null && 'name' in error ? String((error as any).name) : ''
|
||||
|
||||
/**
|
||||
* Extracts a human-readable message from an unknown error
|
||||
*/
|
||||
const getErrorMessage = (error: unknown) =>
|
||||
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
|
||||
|
||||
/**
|
||||
* Checks if an error is an abort error
|
||||
*/
|
||||
const isAbortError = (error: unknown) => getErrorName(error) === 'AbortError'
|
||||
|
||||
/**
|
||||
* Checks if an error is a network-related error
|
||||
*/
|
||||
const isNetworkError = (error: unknown) => {
|
||||
if (!(error instanceof Error)) {
|
||||
return false
|
||||
@@ -197,6 +233,9 @@ interface PresignedUploadInfo {
|
||||
presignedUrls?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes presigned URL response data into a consistent format
|
||||
*/
|
||||
const normalizePresignedData = (data: any, context: string): PresignedUploadInfo => {
|
||||
const presignedUrl = data?.presignedUrl || data?.uploadUrl
|
||||
const fileInfo = data?.fileInfo
|
||||
@@ -221,6 +260,9 @@ const normalizePresignedData = (data: any, context: string): PresignedUploadInfo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches presigned URL data for file upload
|
||||
*/
|
||||
const getPresignedData = async (
|
||||
file: File,
|
||||
timeoutMs: number,
|
||||
@@ -249,7 +291,7 @@ const getPresignedData = async (
|
||||
try {
|
||||
errorDetails = await presignedResponse.json()
|
||||
} catch {
|
||||
// Ignore JSON parsing errors (@Web)
|
||||
errorDetails = null
|
||||
}
|
||||
|
||||
logger.error('Presigned URL request failed', {
|
||||
@@ -279,6 +321,9 @@ const getPresignedData = async (
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing file uploads to knowledge bases
|
||||
*/
|
||||
export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState<UploadProgress>({
|
||||
@@ -288,6 +333,9 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
})
|
||||
const [uploadError, setUploadError] = useState<UploadError | null>(null)
|
||||
|
||||
/**
|
||||
* Creates an UploadedFile object from file metadata
|
||||
*/
|
||||
const createUploadedFile = (
|
||||
filename: string,
|
||||
fileUrl: string,
|
||||
@@ -299,7 +347,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
fileUrl,
|
||||
fileSize,
|
||||
mimeType,
|
||||
// Include tags from original file if available
|
||||
tag1: (originalFile as any)?.tag1,
|
||||
tag2: (originalFile as any)?.tag2,
|
||||
tag3: (originalFile as any)?.tag3,
|
||||
@@ -309,6 +356,9 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
tag7: (originalFile as any)?.tag7,
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates an UploadError from an exception
|
||||
*/
|
||||
const createErrorFromException = (error: unknown, defaultMessage: string): UploadError => {
|
||||
if (error instanceof KnowledgeUploadError) {
|
||||
return {
|
||||
@@ -356,13 +406,11 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
// For large files (>50MB), use multipart upload
|
||||
if (file.size > UPLOAD_CONFIG.LARGE_FILE_THRESHOLD) {
|
||||
presignedData = presignedOverride ?? (await getPresignedData(file, timeoutMs, controller))
|
||||
return await uploadFileInChunks(file, presignedData, timeoutMs, fileIndex)
|
||||
}
|
||||
|
||||
// For all other files, use server-side upload
|
||||
return await uploadFileThroughAPI(file, timeoutMs)
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
@@ -372,7 +420,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
const isNetwork = isNetworkError(error)
|
||||
|
||||
if (retryCount < UPLOAD_CONFIG.MAX_RETRIES) {
|
||||
const delay = UPLOAD_CONFIG.RETRY_DELAY_MS * UPLOAD_CONFIG.RETRY_BACKOFF ** retryCount // More aggressive exponential backoff (@Web)
|
||||
const delay = UPLOAD_CONFIG.RETRY_DELAY_MS * UPLOAD_CONFIG.RETRY_BACKOFF ** retryCount
|
||||
if (isTimeout || isNetwork) {
|
||||
logger.warn(
|
||||
`Upload failed (${isTimeout ? 'timeout' : 'network'}), retrying in ${delay / 1000}s...`,
|
||||
@@ -446,7 +494,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
|
||||
outerController.signal.addEventListener('abort', abortHandler)
|
||||
|
||||
// Track upload progress
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable && fileIndex !== undefined && !isCompleted) {
|
||||
const percentComplete = Math.round((event.loaded / event.total) * 100)
|
||||
@@ -517,10 +564,8 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
|
||||
xhr.addEventListener('abort', abortHandler)
|
||||
|
||||
// Start the upload
|
||||
xhr.open('PUT', presignedData.presignedUrl)
|
||||
|
||||
// Set headers
|
||||
xhr.setRequestHeader('Content-Type', file.type)
|
||||
if (presignedData.uploadHeaders) {
|
||||
Object.entries(presignedData.uploadHeaders).forEach(([key, value]) => {
|
||||
@@ -547,7 +592,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
const startTime = getHighResTime()
|
||||
|
||||
try {
|
||||
// Step 1: Initiate multipart upload
|
||||
const initiateResponse = await fetch('/api/files/multipart?action=initiate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -565,12 +609,10 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
const { uploadId, key } = await initiateResponse.json()
|
||||
logger.info(`Initiated multipart upload with ID: ${uploadId}`)
|
||||
|
||||
// Step 2: Calculate parts
|
||||
const chunkSize = UPLOAD_CONFIG.CHUNK_SIZE
|
||||
const numParts = Math.ceil(file.size / chunkSize)
|
||||
const partNumbers = Array.from({ length: numParts }, (_, i) => i + 1)
|
||||
|
||||
// Step 3: Get presigned URLs for all parts
|
||||
const partUrlsResponse = await fetch('/api/files/multipart?action=get-part-urls', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -593,7 +635,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
|
||||
const { presignedUrls } = await partUrlsResponse.json()
|
||||
|
||||
// Step 4: Upload parts in parallel (batch them to avoid overwhelming the browser)
|
||||
const uploadedParts: Array<{ ETag: string; PartNumber: number }> = []
|
||||
|
||||
const controller = new AbortController()
|
||||
@@ -667,7 +708,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
clearTimeout(multipartTimeoutId)
|
||||
}
|
||||
|
||||
// Step 5: Complete multipart upload
|
||||
const completeResponse = await fetch('/api/files/multipart?action=complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -705,7 +745,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
sizeMB: formatMegabytes(file.size),
|
||||
durationMs: formatDurationSeconds(durationMs),
|
||||
})
|
||||
// Fall back to direct upload if multipart fails
|
||||
return uploadFileDirectly(file, presignedData, timeoutMs, new AbortController(), fileIndex)
|
||||
}
|
||||
}
|
||||
@@ -737,7 +776,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
try {
|
||||
errorData = await uploadResponse.json()
|
||||
} catch {
|
||||
// Ignore JSON parsing errors
|
||||
errorData = null
|
||||
}
|
||||
|
||||
throw new DirectUploadError(
|
||||
@@ -772,11 +811,13 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
/**
|
||||
* Upload files using batch presigned URLs (works for both S3 and Azure Blob)
|
||||
*/
|
||||
/**
|
||||
* Uploads files in batches using presigned URLs
|
||||
*/
|
||||
const uploadFilesInBatches = async (files: File[]): Promise<UploadedFile[]> => {
|
||||
const results: UploadedFile[] = []
|
||||
const failedFiles: Array<{ file: File; error: Error }> = []
|
||||
|
||||
// Initialize file statuses
|
||||
const fileStatuses: FileUploadStatus[] = files.map((file) => ({
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
@@ -925,6 +966,9 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main upload function that handles file uploads and document processing
|
||||
*/
|
||||
const uploadFiles = async (
|
||||
files: File[],
|
||||
knowledgeBaseId: string,
|
||||
@@ -950,7 +994,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
const processPayload = {
|
||||
documents: uploadedFiles.map((file) => ({
|
||||
...file,
|
||||
// Tags are already included in the file object from createUploadedFile
|
||||
})),
|
||||
processingOptions: {
|
||||
chunkSize: processingOptions.chunkSize || 1024,
|
||||
@@ -975,7 +1018,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
try {
|
||||
errorData = await processResponse.json()
|
||||
} catch {
|
||||
// Ignore JSON parsing errors
|
||||
errorData = null
|
||||
}
|
||||
|
||||
logger.error('Document processing failed:', {
|
||||
@@ -1034,9 +1077,12 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const clearError = () => {
|
||||
/**
|
||||
* Clears the current upload error
|
||||
*/
|
||||
const clearError = useCallback(() => {
|
||||
setUploadError(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isUploading,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ChevronDown, LibraryBig, Plus } from 'lucide-react'
|
||||
import { ChevronDown, Database, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
@@ -11,32 +11,37 @@ import {
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
BaseOverview,
|
||||
CreateModal,
|
||||
EmptyStateCard,
|
||||
KnowledgeBaseCardSkeletonGrid,
|
||||
KnowledgeHeader,
|
||||
SearchInput,
|
||||
BaseCard,
|
||||
BaseCardSkeletonGrid,
|
||||
CreateBaseModal,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import {
|
||||
filterButtonClass,
|
||||
SORT_OPTIONS,
|
||||
type SortOption,
|
||||
type SortOrder,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/components/shared'
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/components/constants'
|
||||
import {
|
||||
filterKnowledgeBases,
|
||||
sortKnowledgeBases,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/utils/sort'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useKnowledgeBasesList } from '@/hooks/use-knowledge'
|
||||
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
|
||||
|
||||
/**
|
||||
* Extended knowledge base data with document count
|
||||
*/
|
||||
interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
|
||||
docCount?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Knowledge base list component displaying all knowledge bases in a workspace
|
||||
* Supports filtering by search query and sorting options
|
||||
*/
|
||||
export function Knowledge() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
@@ -46,6 +51,7 @@ export function Knowledge() {
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||
const [isSortPopoverOpen, setIsSortPopoverOpen] = useState(false)
|
||||
const [sortBy, setSortBy] = useState<SortOption>('updatedAt')
|
||||
@@ -55,6 +61,9 @@ export function Knowledge() {
|
||||
const currentSortLabel =
|
||||
SORT_OPTIONS.find((opt) => opt.value === currentSortValue)?.label || 'Last Updated'
|
||||
|
||||
/**
|
||||
* Handles sort option change from dropdown
|
||||
*/
|
||||
const handleSortChange = (value: string) => {
|
||||
const [field, order] = value.split('-') as [SortOption, SortOrder]
|
||||
setSortBy(field)
|
||||
@@ -62,19 +71,32 @@ export function Knowledge() {
|
||||
setIsSortPopoverOpen(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback when a new knowledge base is created
|
||||
*/
|
||||
const handleKnowledgeBaseCreated = (newKnowledgeBase: KnowledgeBaseData) => {
|
||||
addKnowledgeBase(newKnowledgeBase)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry loading knowledge bases after an error
|
||||
*/
|
||||
const handleRetry = () => {
|
||||
refreshList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and sort knowledge bases based on search query and sort options
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const filteredAndSortedKnowledgeBases = useMemo(() => {
|
||||
const filtered = filterKnowledgeBases(knowledgeBases, searchQuery)
|
||||
const filtered = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery)
|
||||
return sortKnowledgeBases(filtered, sortBy, sortOrder)
|
||||
}, [knowledgeBases, searchQuery, sortBy, sortOrder])
|
||||
}, [knowledgeBases, debouncedSearchQuery, sortBy, sortOrder])
|
||||
|
||||
/**
|
||||
* Format knowledge base data for display in the card
|
||||
*/
|
||||
const formatKnowledgeBaseForDisplay = (kb: KnowledgeBaseWithDocCount) => ({
|
||||
id: kb.id,
|
||||
title: kb.name,
|
||||
@@ -84,146 +106,144 @@ export function Knowledge() {
|
||||
updatedAt: kb.updatedAt,
|
||||
})
|
||||
|
||||
const breadcrumbs = [{ id: 'knowledge', label: 'Knowledge' }]
|
||||
/**
|
||||
* Get empty state content based on current filters
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const emptyState = useMemo(() => {
|
||||
if (debouncedSearchQuery) {
|
||||
return {
|
||||
title: 'No knowledge bases found',
|
||||
description: 'Try a different search term',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'No knowledge bases yet',
|
||||
description:
|
||||
userPermissions.canEdit === true
|
||||
? 'Create a knowledge base to get started'
|
||||
: 'Knowledge bases will appear here once created',
|
||||
}
|
||||
}, [debouncedSearchQuery, userPermissions.canEdit])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-screen flex-col pl-64'>
|
||||
{/* Header */}
|
||||
<KnowledgeHeader breadcrumbs={breadcrumbs} />
|
||||
|
||||
<div className='flex h-full flex-1 flex-col'>
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
||||
{/* Main Content */}
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<div className='px-6 pb-6'>
|
||||
{/* Search and Create Section */}
|
||||
<div className='mb-4 flex items-center justify-between pt-1'>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder='Search knowledge bases...'
|
||||
/>
|
||||
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[28px] pb-[24px]'>
|
||||
<div>
|
||||
<div className='flex items-start gap-[12px]'>
|
||||
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#1E5A3E] bg-[#0F3D2C]'>
|
||||
<Database className='h-[14px] w-[14px] text-[#34D399]' />
|
||||
</div>
|
||||
<h1 className='font-medium text-[18px]'>Knowledge Base</h1>
|
||||
</div>
|
||||
<p className='mt-[10px] text-[14px] text-[var(--text-tertiary)]'>
|
||||
Create and manage knowledge bases with custom files.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* Sort Dropdown */}
|
||||
<Popover open={isSortPopoverOpen} onOpenChange={setIsSortPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='outline' className={filterButtonClass}>
|
||||
{currentSortLabel}
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='end' side='bottom' sideOffset={4}>
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-5)] px-[8px]'>
|
||||
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
|
||||
<Input
|
||||
placeholder='Search'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{knowledgeBases.length > 0 && (
|
||||
<Popover open={isSortPopoverOpen} onOpenChange={setIsSortPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='default' className='h-[32px] rounded-[6px]'>
|
||||
{currentSortLabel}
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='end' side='bottom' sideOffset={4}>
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<PopoverItem
|
||||
key={option.value}
|
||||
active={currentSortValue === option.value}
|
||||
showCheck
|
||||
onClick={() => handleSortChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{/* Create Button */}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
disabled={userPermissions.canEdit !== true}
|
||||
variant='primary'
|
||||
className='flex items-center gap-1'
|
||||
>
|
||||
<Plus className='h-3.5 w-3.5' />
|
||||
<span>Create</span>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{userPermissions.canEdit !== true && (
|
||||
<Tooltip.Content>
|
||||
Write permission required to create knowledge bases
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
disabled={userPermissions.canEdit !== true}
|
||||
variant='primary'
|
||||
className='h-[32px] rounded-[6px]'
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{userPermissions.canEdit !== true && (
|
||||
<Tooltip.Content>
|
||||
Write permission required to create knowledge bases
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-[24px] grid grid-cols-1 gap-x-[20px] gap-y-[40px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{isLoading ? (
|
||||
<BaseCardSkeletonGrid count={8} />
|
||||
) : filteredAndSortedKnowledgeBases.length === 0 ? (
|
||||
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
|
||||
<div className='text-center'>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||
{emptyState.title}
|
||||
</p>
|
||||
<p className='mt-1 text-[var(--text-muted)] text-xs'>
|
||||
{emptyState.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className='mb-4 rounded-md border border-red-200 bg-red-50 p-4'>
|
||||
<p className='text-red-800 text-sm'>Error loading knowledge bases: {error}</p>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className='mt-2 text-red-600 text-sm underline hover:text-red-800'
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
) : error ? (
|
||||
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
|
||||
<div className='text-center'>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||
Error loading knowledge bases
|
||||
</p>
|
||||
<p className='mt-1 text-[var(--text-muted)] text-xs'>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Area */}
|
||||
{isLoading ? (
|
||||
<KnowledgeBaseCardSkeletonGrid count={8} />
|
||||
) : (
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{filteredAndSortedKnowledgeBases.length === 0 ? (
|
||||
knowledgeBases.length === 0 ? (
|
||||
<EmptyStateCard
|
||||
title='Create your first knowledge base'
|
||||
description={
|
||||
userPermissions.canEdit === true
|
||||
? 'Upload your documents to create a knowledge base for your agents.'
|
||||
: 'Knowledge bases will appear here. Contact an admin to create knowledge bases.'
|
||||
}
|
||||
buttonText={
|
||||
userPermissions.canEdit === true
|
||||
? 'Create Knowledge Base'
|
||||
: 'Contact Admin'
|
||||
}
|
||||
onClick={
|
||||
userPermissions.canEdit === true
|
||||
? () => setIsCreateModalOpen(true)
|
||||
: () => {}
|
||||
}
|
||||
icon={<LibraryBig className='h-4 w-4 text-muted-foreground' />}
|
||||
/>
|
||||
) : (
|
||||
<div className='col-span-full py-12 text-center'>
|
||||
<p className='text-muted-foreground'>
|
||||
No knowledge bases match your search.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
filteredAndSortedKnowledgeBases.map((kb) => {
|
||||
const displayData = formatKnowledgeBaseForDisplay(
|
||||
kb as KnowledgeBaseWithDocCount
|
||||
)
|
||||
return (
|
||||
<BaseOverview
|
||||
key={kb.id}
|
||||
id={displayData.id}
|
||||
title={displayData.title}
|
||||
docCount={displayData.docCount}
|
||||
description={displayData.description}
|
||||
createdAt={displayData.createdAt}
|
||||
updatedAt={displayData.updatedAt}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
filteredAndSortedKnowledgeBases.map((kb) => {
|
||||
const displayData = formatKnowledgeBaseForDisplay(kb as KnowledgeBaseWithDocCount)
|
||||
return (
|
||||
<BaseCard
|
||||
key={kb.id}
|
||||
id={displayData.id}
|
||||
title={displayData.title}
|
||||
docCount={displayData.docCount}
|
||||
description={displayData.description}
|
||||
createdAt={displayData.createdAt}
|
||||
updatedAt={displayData.updatedAt}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
<CreateModal
|
||||
<CreateBaseModal
|
||||
open={isCreateModalOpen}
|
||||
onOpenChange={setIsCreateModalOpen}
|
||||
onKnowledgeBaseCreated={handleKnowledgeBaseCreated}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Knowledge Base layout - applies sidebar padding for all knowledge routes.
|
||||
*/
|
||||
export default function KnowledgeLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className='flex h-full flex-1 flex-col pl-60'>{children}</div>
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { Button, Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
import {
|
||||
KnowledgeBaseCardSkeletonGrid,
|
||||
KnowledgeHeader,
|
||||
SearchInput,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import {
|
||||
filterButtonClass,
|
||||
SORT_OPTIONS,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/components/shared'
|
||||
|
||||
export default function KnowledgeLoading() {
|
||||
const breadcrumbs = [{ id: 'knowledge', label: 'Knowledge' }]
|
||||
const currentSortLabel = SORT_OPTIONS[0]?.label || 'Last Updated'
|
||||
|
||||
return (
|
||||
<div className='flex h-screen flex-col pl-64'>
|
||||
{/* Header */}
|
||||
<KnowledgeHeader breadcrumbs={breadcrumbs} />
|
||||
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
||||
{/* Main Content */}
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<div className='px-6 pb-6'>
|
||||
{/* Search and Create Section */}
|
||||
<div className='mb-4 flex items-center justify-between pt-1'>
|
||||
<SearchInput
|
||||
value=''
|
||||
onChange={() => {}}
|
||||
placeholder='Search knowledge bases...'
|
||||
disabled
|
||||
/>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* Sort Dropdown */}
|
||||
<Popover open={false}>
|
||||
<PopoverAnchor asChild>
|
||||
<Button variant='outline' className={filterButtonClass} disabled>
|
||||
{currentSortLabel}
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
</Button>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent align='end' side='bottom' sideOffset={4}>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<PopoverItem key={option.value} disabled>
|
||||
{option.label}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Create Button */}
|
||||
<Button disabled variant='primary' className='flex items-center gap-1'>
|
||||
<div className='h-3.5 w-3.5 animate-pulse rounded bg-primary-foreground/30' />
|
||||
<span>Create</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<KnowledgeBaseCardSkeletonGrid count={8} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1 @@
|
||||
import { Knowledge } from '@/app/workspace/[workspaceId]/knowledge/knowledge'
|
||||
|
||||
export default function KnowledgePage() {
|
||||
return <Knowledge />
|
||||
}
|
||||
export { Knowledge as default } from './knowledge'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
|
||||
import type { SortOption, SortOrder } from '../components/shared'
|
||||
import type { SortOption, SortOrder } from '../components/constants'
|
||||
|
||||
interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
|
||||
docCount?: number
|
||||
|
||||
@@ -5,7 +5,7 @@ import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/
|
||||
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
|
||||
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
|
||||
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { SidebarNew } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new'
|
||||
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||
|
||||
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -14,10 +14,10 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
|
||||
<ProviderModelsLoader />
|
||||
<GlobalCommandsProvider>
|
||||
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
|
||||
<div className='flex min-h-screen w-full'>
|
||||
<div className='flex h-screen w-full'>
|
||||
<WorkspacePermissionsProvider>
|
||||
<div className='shrink-0' suppressHydrationWarning>
|
||||
<SidebarNew />
|
||||
<Sidebar />
|
||||
</div>
|
||||
{children}
|
||||
</WorkspacePermissionsProvider>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { LineChart, type LineChartMultiSeries, type LineChartPoint } from './line-chart'
|
||||
export { StatusBar, type StatusBarSegment } from './status-bar'
|
||||
export { type WorkflowExecutionItem, WorkflowsList } from './workflows-list'
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { LineChartMultiSeries, LineChartPoint } from './line-chart'
|
||||
export { default, LineChart } from './line-chart'
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDate, formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
|
||||
export interface LineChartPoint {
|
||||
timestamp: string
|
||||
@@ -28,6 +29,7 @@ export function LineChart({
|
||||
series?: LineChartMultiSeries[]
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const uniqueId = useRef(`chart-${Math.random().toString(36).substring(2, 9)}`).current
|
||||
const [containerWidth, setContainerWidth] = useState<number>(420)
|
||||
const width = containerWidth
|
||||
const height = 166
|
||||
@@ -54,6 +56,7 @@ export function LineChart({
|
||||
const [hoverSeriesId, setHoverSeriesId] = useState<string | null>(null)
|
||||
const [activeSeriesId, setActiveSeriesId] = useState<string | null>(null)
|
||||
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const [resolvedColors, setResolvedColors] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
@@ -65,10 +68,40 @@ export function LineChart({
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const resolveColor = (c: string): string => {
|
||||
if (!c.startsWith('var(')) return c
|
||||
|
||||
const tempEl = document.createElement('div')
|
||||
tempEl.style.color = c
|
||||
document.body.appendChild(tempEl)
|
||||
const computed = window.getComputedStyle(tempEl).color
|
||||
document.body.removeChild(tempEl)
|
||||
return computed
|
||||
}
|
||||
|
||||
const colorMap: Record<string, string> = { base: resolveColor(color) }
|
||||
const allSeriesToResolve = Array.isArray(series) && series.length > 0 ? series : []
|
||||
|
||||
for (const s of allSeriesToResolve) {
|
||||
const id = s.id || s.label || ''
|
||||
if (id) colorMap[id] = resolveColor(s.color)
|
||||
}
|
||||
|
||||
setResolvedColors(colorMap)
|
||||
}, [color, series])
|
||||
|
||||
const hasExternalWrapper = !label || label === ''
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className='flex items-center justify-center rounded-lg border bg-card p-4'
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
!hasExternalWrapper && 'rounded-lg border bg-card p-4'
|
||||
)}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<p className='text-muted-foreground text-sm'>No data</p>
|
||||
@@ -90,9 +123,19 @@ export function LineChart({
|
||||
const unitSuffixPre = (unit || '').trim().toLowerCase()
|
||||
let maxValue = Math.ceil(paddedMax)
|
||||
let minValue = Math.floor(paddedMin)
|
||||
if (unitSuffixPre === 'ms') {
|
||||
maxValue = Math.max(1000, Math.ceil(paddedMax / 1000) * 1000)
|
||||
if (unitSuffixPre === 'ms' || unitSuffixPre === 'latency') {
|
||||
minValue = 0
|
||||
if (paddedMax < 10) {
|
||||
maxValue = Math.ceil(paddedMax)
|
||||
} else if (paddedMax < 100) {
|
||||
maxValue = Math.ceil(paddedMax / 10) * 10
|
||||
} else if (paddedMax < 1000) {
|
||||
maxValue = Math.ceil(paddedMax / 50) * 50
|
||||
} else if (paddedMax < 10000) {
|
||||
maxValue = Math.ceil(paddedMax / 500) * 500
|
||||
} else {
|
||||
maxValue = Math.ceil(paddedMax / 1000) * 1000
|
||||
}
|
||||
}
|
||||
const valueRange = maxValue - minValue || 1
|
||||
|
||||
@@ -152,7 +195,7 @@ export function LineChart({
|
||||
if (!timestamp) return ''
|
||||
try {
|
||||
const f = formatDate(timestamp)
|
||||
return `${f.compactDate} · ${f.compactTime}`
|
||||
return `${f.compactDate} ${f.compactTime}`
|
||||
} catch (e) {
|
||||
const d = new Date(timestamp)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
@@ -172,51 +215,58 @@ export function LineChart({
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='w-full overflow-hidden rounded-[11px] border bg-card p-4 shadow-sm'
|
||||
className={cn(
|
||||
'w-full overflow-hidden',
|
||||
!hasExternalWrapper && 'rounded-[11px] border bg-card p-4 shadow-sm'
|
||||
)}
|
||||
>
|
||||
<div className='mb-3 flex items-center gap-3'>
|
||||
<h4 className='font-medium text-foreground text-sm'>{label}</h4>
|
||||
{allSeries.length > 1 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
{scaledSeries.slice(1).map((s) => {
|
||||
const isActive = activeSeriesId ? activeSeriesId === s.id : true
|
||||
const isHovered = hoverSeriesId === s.id
|
||||
const dimmed = activeSeriesId ? !isActive : false
|
||||
return (
|
||||
<button
|
||||
key={`legend-${s.id}`}
|
||||
type='button'
|
||||
aria-pressed={activeSeriesId === s.id}
|
||||
aria-label={`Toggle ${s.label}`}
|
||||
className='inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px]'
|
||||
style={{
|
||||
color: s.color,
|
||||
opacity: dimmed ? 0.4 : isHovered ? 1 : 0.9,
|
||||
border: '1px solid hsl(var(--border))',
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={() => setHoverSeriesId(s.id || null)}
|
||||
onMouseLeave={() => setHoverSeriesId((prev) => (prev === s.id ? null : prev))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
{!hasExternalWrapper && (
|
||||
<div className='mb-3 flex items-center gap-3'>
|
||||
<h4 className='font-medium text-foreground text-sm'>{label}</h4>
|
||||
{allSeries.length > 1 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
{scaledSeries.slice(1).map((s) => {
|
||||
const isActive = activeSeriesId ? activeSeriesId === s.id : true
|
||||
const isHovered = hoverSeriesId === s.id
|
||||
const dimmed = activeSeriesId ? !isActive : false
|
||||
return (
|
||||
<button
|
||||
key={`legend-${s.id}`}
|
||||
type='button'
|
||||
aria-pressed={activeSeriesId === s.id}
|
||||
aria-label={`Toggle ${s.label}`}
|
||||
className='inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px]'
|
||||
style={{
|
||||
color: resolvedColors[s.id || ''] || s.color,
|
||||
opacity: dimmed ? 0.4 : isHovered ? 1 : 0.9,
|
||||
border: '1px solid hsl(var(--border))',
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={() => setHoverSeriesId(s.id || null)}
|
||||
onMouseLeave={() => setHoverSeriesId((prev) => (prev === s.id ? null : prev))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))
|
||||
}
|
||||
}}
|
||||
onClick={() =>
|
||||
setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))
|
||||
}
|
||||
}}
|
||||
onClick={() => setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))}
|
||||
>
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='inline-block h-[6px] w-[6px] rounded-full'
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
<span style={{ color: 'hsl(var(--muted-foreground))' }}>{s.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='inline-block h-[6px] w-[6px] rounded-[2px]'
|
||||
style={{ backgroundColor: resolvedColors[s.id || ''] || s.color }}
|
||||
/>
|
||||
<span style={{ color: 'hsl(var(--muted-foreground))' }}>{s.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className='relative' style={{ width, height }}>
|
||||
<svg
|
||||
width={width}
|
||||
@@ -255,15 +305,23 @@ export function LineChart({
|
||||
}}
|
||||
>
|
||||
<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 id={`area-${uniqueId}`} x1='0' x2='0' y1='0' y2='1'>
|
||||
<stop
|
||||
offset='0%'
|
||||
stopColor={resolvedColors.base || color}
|
||||
stopOpacity={isDark ? 0.25 : 0.45}
|
||||
/>
|
||||
<stop
|
||||
offset='100%'
|
||||
stopColor={resolvedColors.base || color}
|
||||
stopOpacity={isDark ? 0.03 : 0.08}
|
||||
/>
|
||||
</linearGradient>
|
||||
<clipPath id={`clip-${label.replace(/\s+/g, '-')}`}>
|
||||
<clipPath id={`clip-${uniqueId}`}>
|
||||
<rect
|
||||
x={padding.left}
|
||||
x={padding.left - 3}
|
||||
y={yMin}
|
||||
width={Math.max(1, chartWidth)}
|
||||
width={Math.max(1, chartWidth + 6)}
|
||||
height={chartHeight - (yMin - padding.top) * 2}
|
||||
rx='2'
|
||||
/>
|
||||
@@ -281,7 +339,7 @@ export function LineChart({
|
||||
|
||||
{[0.25, 0.5, 0.75].map((p) => (
|
||||
<line
|
||||
key={`${label}-grid-${p}`}
|
||||
key={`${uniqueId}-grid-${p}`}
|
||||
x1={padding.left}
|
||||
y1={padding.top + chartHeight * p}
|
||||
x2={width - padding.right}
|
||||
@@ -292,17 +350,32 @@ export function LineChart({
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* axis baseline is drawn last (after line) to visually mask any overshoot */}
|
||||
|
||||
{!activeSeriesId && 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, '-')})`}
|
||||
fill={`url(#area-${uniqueId})`}
|
||||
stroke='none'
|
||||
clipPath={`url(#clip-${label.replace(/\s+/g, '-')})`}
|
||||
clipPath={`url(#clip-${uniqueId})`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!activeSeriesId &&
|
||||
scaledPoints.length === 1 &&
|
||||
(() => {
|
||||
const strokeWidth = isDark ? 1.7 : 2.0
|
||||
const capExtension = strokeWidth / 2
|
||||
return (
|
||||
<rect
|
||||
x={padding.left - capExtension}
|
||||
y={scaledPoints[0].y}
|
||||
width={Math.max(1, chartWidth + capExtension * 2)}
|
||||
height={height - padding.bottom - scaledPoints[0].y}
|
||||
fill={`url(#area-${uniqueId})`}
|
||||
clipPath={`url(#clip-${uniqueId})`}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{orderedSeries.map((s, idx) => {
|
||||
const isActive = activeSeriesId ? activeSeriesId === s.id : true
|
||||
const isHovered = hoverSeriesId ? hoverSeriesId === s.id : false
|
||||
@@ -321,14 +394,20 @@ export function LineChart({
|
||||
}
|
||||
})()
|
||||
if (s.pts.length <= 1) {
|
||||
const y = s.pts[0]?.y
|
||||
if (y === undefined) return null
|
||||
return (
|
||||
<circle
|
||||
<line
|
||||
key={`pt-${idx}`}
|
||||
cx={s.pts[0]?.x}
|
||||
cy={s.pts[0]?.y}
|
||||
r='3'
|
||||
fill={s.color}
|
||||
x1={padding.left}
|
||||
y1={y}
|
||||
x2={width - padding.right}
|
||||
y2={y}
|
||||
stroke={resolvedColors[s.id || ''] || s.color}
|
||||
strokeWidth={sw}
|
||||
strokeLinecap='round'
|
||||
opacity={strokeOpacity}
|
||||
strokeDasharray={s.dashed ? '5 4' : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -356,11 +435,11 @@ export function LineChart({
|
||||
key={`series-${idx}`}
|
||||
d={p}
|
||||
fill='none'
|
||||
stroke={s.color}
|
||||
stroke={resolvedColors[s.id || ''] || s.color}
|
||||
strokeWidth={sw}
|
||||
strokeLinecap='round'
|
||||
clipPath={`url(#clip-${label.replace(/\s+/g, '-')})`}
|
||||
style={{ cursor: 'pointer', mixBlendMode: isDark ? 'screen' : 'normal' }}
|
||||
clipPath={`url(#clip-${uniqueId})`}
|
||||
style={{ mixBlendMode: isDark ? 'screen' : 'normal' }}
|
||||
strokeDasharray={s.dashed ? '5 4' : undefined}
|
||||
opacity={strokeOpacity}
|
||||
onClick={() => setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))}
|
||||
@@ -377,13 +456,13 @@ export function LineChart({
|
||||
const active = guideSeries
|
||||
const pt = active.pts[hoverIndex] || scaledPoints[hoverIndex]
|
||||
return (
|
||||
<g pointerEvents='none' clipPath={`url(#clip-${label.replace(/\s+/g, '-')})`}>
|
||||
<g pointerEvents='none' clipPath={`url(#clip-${uniqueId})`}>
|
||||
<line
|
||||
x1={pt.x}
|
||||
y1={padding.top}
|
||||
x2={pt.x}
|
||||
y2={height - padding.bottom}
|
||||
stroke={active.color}
|
||||
stroke={resolvedColors[active.id || ''] || active.color}
|
||||
strokeOpacity='0.35'
|
||||
strokeDasharray='3 3'
|
||||
/>
|
||||
@@ -392,7 +471,14 @@ export function LineChart({
|
||||
const s = getSeriesById(activeSeriesId)
|
||||
const spt = s?.pts?.[hoverIndex]
|
||||
if (!s || !spt) return null
|
||||
return <circle cx={spt.x} cy={spt.y} r='3' fill={s.color} />
|
||||
return (
|
||||
<circle
|
||||
cx={spt.x}
|
||||
cy={spt.y}
|
||||
r='3'
|
||||
fill={resolvedColors[s.id || ''] || s.color}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</g>
|
||||
)
|
||||
@@ -439,12 +525,12 @@ export function LineChart({
|
||||
const labelStr = Number.isNaN(ts.getTime()) ? '' : formatTick(ts)
|
||||
return (
|
||||
<text
|
||||
key={`${label}-x-axis-${i}`}
|
||||
key={`${uniqueId}-x-axis-${i}`}
|
||||
x={x}
|
||||
y={height - padding.bottom + 14}
|
||||
fontSize='9'
|
||||
textAnchor='middle'
|
||||
fill='hsl(var(--muted-foreground))'
|
||||
fill='var(--text-tertiary)'
|
||||
>
|
||||
{labelStr}
|
||||
</text>
|
||||
@@ -455,13 +541,19 @@ export function LineChart({
|
||||
{(() => {
|
||||
const unitSuffix = (unit || '').trim()
|
||||
const showInTicks = unitSuffix === '%'
|
||||
const fmtCompact = (v: number) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
const isLatency = unitSuffix.toLowerCase() === 'latency'
|
||||
const fmtCompact = (v: number) => {
|
||||
if (isLatency) {
|
||||
if (v === 0) return '0'
|
||||
return formatLatency(v)
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
.format(v)
|
||||
.toLowerCase()
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<text
|
||||
@@ -469,20 +561,20 @@ export function LineChart({
|
||||
y={padding.top}
|
||||
textAnchor='end'
|
||||
fontSize='9'
|
||||
fill='hsl(var(--muted-foreground))'
|
||||
fill='var(--text-tertiary)'
|
||||
>
|
||||
{fmtCompact(maxValue)}
|
||||
{showInTicks ? unit : ''}
|
||||
{showInTicks && !isLatency ? unit : ''}
|
||||
</text>
|
||||
<text
|
||||
x={padding.left - 8}
|
||||
y={height - padding.bottom}
|
||||
textAnchor='end'
|
||||
fontSize='9'
|
||||
fill='hsl(var(--muted-foreground))'
|
||||
fill='var(--text-tertiary)'
|
||||
>
|
||||
{fmtCompact(minValue)}
|
||||
{showInTicks ? unit : ''}
|
||||
{showInTicks && !isLatency ? unit : ''}
|
||||
</text>
|
||||
</>
|
||||
)
|
||||
@@ -498,8 +590,6 @@ export function LineChart({
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* No end labels to keep the chart clean and avoid edge overlap */}
|
||||
|
||||
{hoverIndex !== null &&
|
||||
scaledPoints[hoverIndex] &&
|
||||
(() => {
|
||||
@@ -516,6 +606,7 @@ export function LineChart({
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) return '—'
|
||||
const u = unit || ''
|
||||
if (u.includes('%')) return `${v.toFixed(1)}%`
|
||||
if (u.toLowerCase() === 'latency') return formatLatency(v)
|
||||
if (u.toLowerCase().includes('ms')) return `${Math.round(v)}ms`
|
||||
if (u.toLowerCase().includes('exec')) return `${Math.round(v)}`
|
||||
return `${Math.round(v)}${u}`
|
||||
@@ -546,25 +637,30 @@ export function LineChart({
|
||||
const top = Math.min(Math.max(anchorY - 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'
|
||||
className='pointer-events-none absolute rounded-[8px] border border-[var(--border-strong)] bg-[var(--surface-1)] px-[8px] py-[6px] font-medium text-[11px] shadow-lg'
|
||||
style={{ left, top }}
|
||||
>
|
||||
{currentHoverDate && (
|
||||
<div className='mb-1 text-[10px] text-muted-foreground'>{currentHoverDate}</div>
|
||||
<div className='mb-1 text-[10px] text-[var(--text-tertiary)]'>
|
||||
{currentHoverDate}
|
||||
</div>
|
||||
)}
|
||||
{toDisplay.map((s) => {
|
||||
const seriesIndex = allSeries.findIndex((x) => x.id === s.id)
|
||||
const val = allSeries[seriesIndex]?.data?.[hoverIndex]?.value
|
||||
const seriesLabel = s.label || s.id
|
||||
const showLabel =
|
||||
seriesLabel && seriesLabel !== 'base' && seriesLabel.trim() !== ''
|
||||
return (
|
||||
<div key={`tt-${s.id}`} className='flex items-center gap-1'>
|
||||
<div key={`tt-${s.id}`} className='flex items-center gap-[8px]'>
|
||||
<span
|
||||
className='inline-block h-[6px] w-[6px] rounded-full'
|
||||
style={{ backgroundColor: s.color }}
|
||||
className='inline-block h-[6px] w-[6px] rounded-[2px]'
|
||||
style={{ backgroundColor: resolvedColors[s.id || ''] || s.color }}
|
||||
/>
|
||||
<span style={{ color: 'hsl(var(--muted-foreground))' }}>
|
||||
{s.label || s.id}
|
||||
</span>
|
||||
<span>{fmt(val)}</span>
|
||||
{showLabel && (
|
||||
<span className='text-[var(--text-secondary)]'>{seriesLabel}</span>
|
||||
)}
|
||||
<span className='text-[var(--text-primary)]'>{fmt(val)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { StatusBarSegment } from './status-bar'
|
||||
export { default, StatusBar } from './status-bar'
|
||||
@@ -57,21 +57,28 @@ export function StatusBar({
|
||||
: false
|
||||
|
||||
let color: string
|
||||
let hoverBrightness: string
|
||||
if (!segment.hasExecutions) {
|
||||
color = 'bg-gray-300/60 dark:bg-gray-500/40'
|
||||
hoverBrightness = 'hover:brightness-200'
|
||||
} else if (segment.successRate === 100) {
|
||||
color = 'bg-emerald-400/90'
|
||||
hoverBrightness = 'hover:brightness-110'
|
||||
} else if (segment.successRate >= 95) {
|
||||
color = 'bg-amber-400/90'
|
||||
hoverBrightness = 'hover:brightness-110'
|
||||
} else {
|
||||
color = 'bg-red-400/90'
|
||||
hoverBrightness = 'hover:brightness-110'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
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'
|
||||
className={`h-6 flex-1 rounded-[3px] ${color} ${hoverBrightness} cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'relative z-10 scale-105 shadow-sm ring-1 ring-[var(--text-secondary)]'
|
||||
: 'relative z-0'
|
||||
}`}
|
||||
aria-label={`Segment ${i + 1}`}
|
||||
onMouseEnter={() => setHoverIndex(i)}
|
||||
@@ -90,7 +97,7 @@ export function StatusBar({
|
||||
|
||||
{hoverIndex !== null && segments[hoverIndex] && (
|
||||
<div
|
||||
className={`-translate-x-1/2 pointer-events-none absolute z-20 w-max whitespace-nowrap rounded-md bg-background/90 px-2 py-1 text-center text-[11px] shadow-sm ring-1 ring-border backdrop-blur ${
|
||||
className={`-translate-x-1/2 pointer-events-none absolute z-20 w-max whitespace-nowrap rounded-[8px] border border-[var(--border-strong)] bg-[var(--surface-1)] px-[8px] py-[6px] text-center text-[11px] shadow-lg ${
|
||||
preferBelow ? '' : '-translate-y-full'
|
||||
}`}
|
||||
style={{
|
||||
@@ -101,14 +108,18 @@ export function StatusBar({
|
||||
>
|
||||
{segments[hoverIndex].hasExecutions ? (
|
||||
<div>
|
||||
<div className='font-semibold'>{labels[hoverIndex].successLabel}</div>
|
||||
<div className='text-muted-foreground'>{labels[hoverIndex].countsLabel}</div>
|
||||
<div className='font-semibold text-[var(--text-primary)]'>
|
||||
{labels[hoverIndex].successLabel}
|
||||
</div>
|
||||
<div className='text-[var(--text-secondary)]'>{labels[hoverIndex].countsLabel}</div>
|
||||
{labels[hoverIndex].rangeLabel && (
|
||||
<div className='mt-0.5 text-muted-foreground'>{labels[hoverIndex].rangeLabel}</div>
|
||||
<div className='mt-0.5 text-[var(--text-tertiary)]'>
|
||||
{labels[hoverIndex].rangeLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-muted-foreground'>{labels[hoverIndex].rangeLabel}</div>
|
||||
<div className='text-[var(--text-secondary)]'>{labels[hoverIndex].rangeLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { WorkflowExecutionItem } from './workflows-list'
|
||||
export { default, WorkflowsList } from './workflows-list'
|
||||
@@ -0,0 +1,115 @@
|
||||
import { memo } from 'react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { StatusBar, type StatusBarSegment } from '..'
|
||||
|
||||
export interface WorkflowExecutionItem {
|
||||
workflowId: string
|
||||
workflowName: string
|
||||
segments: StatusBarSegment[]
|
||||
overallSuccessRate: number
|
||||
}
|
||||
|
||||
export function WorkflowsList({
|
||||
executions,
|
||||
filteredExecutions,
|
||||
expandedWorkflowId,
|
||||
onToggleWorkflow,
|
||||
selectedSegments,
|
||||
onSegmentClick,
|
||||
searchQuery,
|
||||
segmentDurationMs,
|
||||
}: {
|
||||
executions: WorkflowExecutionItem[]
|
||||
filteredExecutions: WorkflowExecutionItem[]
|
||||
expandedWorkflowId: string | null
|
||||
onToggleWorkflow: (workflowId: string) => void
|
||||
selectedSegments: Record<string, number[]>
|
||||
onSegmentClick: (
|
||||
workflowId: string,
|
||||
segmentIndex: number,
|
||||
timestamp: string,
|
||||
mode: 'single' | 'toggle' | 'range'
|
||||
) => void
|
||||
searchQuery: string
|
||||
segmentDurationMs: number
|
||||
}) {
|
||||
const { workflows } = useWorkflowRegistry()
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col overflow-hidden rounded-[6px] bg-[var(--surface-1)]'>
|
||||
{/* Table header */}
|
||||
<div className='flex-shrink-0 rounded-t-[6px] bg-[var(--surface-3)] px-[24px] py-[10px]'>
|
||||
<div className='flex items-center gap-[16px]'>
|
||||
<span className='w-[160px] flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Workflow
|
||||
</span>
|
||||
<span className='flex-1 font-medium text-[12px] text-[var(--text-tertiary)]'>Logs</span>
|
||||
<span className='w-[100px] flex-shrink-0 pl-[16px] font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Success Rate
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table body - scrollable */}
|
||||
<div className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden'>
|
||||
{filteredExecutions.length === 0 ? (
|
||||
<div className='flex items-center justify-center py-[32px]'>
|
||||
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||
{searchQuery ? `No workflows found matching "${searchQuery}"` : 'No workflows found'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{filteredExecutions.map((workflow, idx) => {
|
||||
const isSelected = expandedWorkflowId === workflow.workflowId
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workflow.workflowId}
|
||||
className={cn(
|
||||
'flex h-[44px] cursor-pointer items-center gap-[16px] px-[24px] hover:bg-[var(--c-2A2A2A)]',
|
||||
isSelected && 'bg-[var(--c-2A2A2A)]'
|
||||
)}
|
||||
onClick={() => onToggleWorkflow(workflow.workflowId)}
|
||||
>
|
||||
{/* Workflow name with color */}
|
||||
<div className='flex w-[160px] flex-shrink-0 items-center gap-[8px] pr-[8px]'>
|
||||
<div
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
|
||||
style={{
|
||||
backgroundColor: workflows[workflow.workflowId]?.color || '#64748b',
|
||||
}}
|
||||
/>
|
||||
<span className='min-w-0 truncate font-medium text-[12px] text-[var(--text-primary)]'>
|
||||
{workflow.workflowName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status bar - takes most of the space */}
|
||||
<div className='flex-1'>
|
||||
<StatusBar
|
||||
segments={workflow.segments}
|
||||
selectedSegmentIndices={selectedSegments[workflow.workflowId] || null}
|
||||
onSegmentClick={onSegmentClick as any}
|
||||
workflowId={workflow.workflowId}
|
||||
segmentDurationMs={segmentDurationMs}
|
||||
preferBelow={idx < 2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Success rate */}
|
||||
<span className='w-[100px] flex-shrink-0 pl-[16px] font-medium text-[12px] text-[var(--text-primary)]'>
|
||||
{workflow.overallSuccessRate.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(WorkflowsList)
|
||||
@@ -0,0 +1,887 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
formatLatency,
|
||||
mapToExecutionLog,
|
||||
mapToExecutionLogAlt,
|
||||
} from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import {
|
||||
useExecutionsMetrics,
|
||||
useGlobalDashboardLogs,
|
||||
useWorkflowDashboardLogs,
|
||||
} from '@/hooks/queries/logs'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { LineChart, WorkflowsList } from './components'
|
||||
|
||||
type TimeFilter = '30m' | '1h' | '6h' | '12h' | '24h' | '3d' | '7d' | '14d' | '30d'
|
||||
|
||||
interface WorkflowExecution {
|
||||
workflowId: string
|
||||
workflowName: string
|
||||
segments: {
|
||||
successRate: number
|
||||
timestamp: string
|
||||
hasExecutions: boolean
|
||||
totalExecutions: number
|
||||
successfulExecutions: number
|
||||
avgDurationMs?: number
|
||||
p50Ms?: number
|
||||
p90Ms?: number
|
||||
p99Ms?: number
|
||||
}[]
|
||||
overallSuccessRate: number
|
||||
}
|
||||
|
||||
const DEFAULT_SEGMENTS = 72
|
||||
const MIN_SEGMENT_PX = 10
|
||||
|
||||
/**
|
||||
* Skeleton loader for a single graph card
|
||||
*/
|
||||
function GraphCardSkeleton({ title }: { title: string }) {
|
||||
return (
|
||||
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
|
||||
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
|
||||
{title}
|
||||
</span>
|
||||
<Skeleton className='h-[20px] w-[40px]' />
|
||||
</div>
|
||||
<div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-1)] px-[14px] py-[10px]'>
|
||||
<div className='flex h-[166px] flex-col justify-end gap-[4px]'>
|
||||
{/* Skeleton bars simulating chart */}
|
||||
<div className='flex items-end gap-[2px]'>
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className='flex-1'
|
||||
style={{
|
||||
height: `${Math.random() * 80 + 20}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton loader for a workflow row in the workflows list
|
||||
*/
|
||||
function WorkflowRowSkeleton() {
|
||||
return (
|
||||
<div className='flex h-[44px] items-center gap-[16px] px-[24px]'>
|
||||
{/* Workflow name with color */}
|
||||
<div className='flex w-[160px] flex-shrink-0 items-center gap-[8px] pr-[8px]'>
|
||||
<Skeleton className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]' />
|
||||
<Skeleton className='h-[16px] flex-1' />
|
||||
</div>
|
||||
|
||||
{/* Status bar - takes most of the space */}
|
||||
<div className='flex-1'>
|
||||
<Skeleton className='h-[24px] w-full rounded-[4px]' />
|
||||
</div>
|
||||
|
||||
{/* Success rate */}
|
||||
<div className='w-[100px] flex-shrink-0 pl-[16px]'>
|
||||
<Skeleton className='h-[16px] w-[50px]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton loader for the workflows list table
|
||||
*/
|
||||
function WorkflowsListSkeleton({ rowCount = 5 }: { rowCount?: number }) {
|
||||
return (
|
||||
<div className='flex h-full flex-col overflow-hidden rounded-[6px] bg-[var(--surface-1)]'>
|
||||
{/* Table header */}
|
||||
<div className='flex-shrink-0 rounded-t-[6px] bg-[var(--surface-3)] px-[24px] py-[10px]'>
|
||||
<div className='flex items-center gap-[16px]'>
|
||||
<span className='w-[160px] flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Workflow
|
||||
</span>
|
||||
<span className='flex-1 font-medium text-[12px] text-[var(--text-tertiary)]'>Logs</span>
|
||||
<span className='w-[100px] flex-shrink-0 pl-[16px] font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Success Rate
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table body - scrollable */}
|
||||
<div className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden'>
|
||||
{Array.from({ length: rowCount }).map((_, i) => (
|
||||
<WorkflowRowSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete skeleton loader for the entire dashboard
|
||||
*/
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className='mt-[24px] flex min-h-0 flex-1 flex-col'>
|
||||
{/* Graphs Section */}
|
||||
<div className='mb-[16px] flex-shrink-0'>
|
||||
<div className='grid grid-cols-1 gap-[16px] md:grid-cols-3'>
|
||||
<GraphCardSkeleton title='Runs' />
|
||||
<GraphCardSkeleton title='Errors' />
|
||||
<GraphCardSkeleton title='Latency' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflows Table - takes remaining space */}
|
||||
<div className='min-h-0 flex-1 overflow-hidden'>
|
||||
<WorkflowsListSkeleton rowCount={14} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DashboardProps {
|
||||
isLive?: boolean
|
||||
refreshTrigger?: number
|
||||
onCustomTimeRangeChange?: (isCustom: boolean) => void
|
||||
}
|
||||
|
||||
export default function Dashboard({
|
||||
isLive = false,
|
||||
refreshTrigger = 0,
|
||||
onCustomTimeRangeChange,
|
||||
}: DashboardProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const getTimeFilterFromRange = (range: string): TimeFilter => {
|
||||
switch (range) {
|
||||
case 'Past 30 minutes':
|
||||
return '30m'
|
||||
case 'Past hour':
|
||||
return '1h'
|
||||
case 'Past 6 hours':
|
||||
return '6h'
|
||||
case 'Past 12 hours':
|
||||
return '12h'
|
||||
case 'Past 24 hours':
|
||||
return '24h'
|
||||
case 'Past 3 days':
|
||||
return '3d'
|
||||
case 'Past 7 days':
|
||||
return '7d'
|
||||
case 'Past 14 days':
|
||||
return '14d'
|
||||
case 'Past 30 days':
|
||||
return '30d'
|
||||
default:
|
||||
return '30d'
|
||||
}
|
||||
}
|
||||
|
||||
const [endTime, setEndTime] = useState<Date>(new Date())
|
||||
const [expandedWorkflowId, setExpandedWorkflowId] = useState<string | null>(null)
|
||||
const [selectedSegments, setSelectedSegments] = useState<Record<string, number[]>>({})
|
||||
const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
|
||||
const [segmentCount, setSegmentCount] = useState<number>(DEFAULT_SEGMENTS)
|
||||
const barsAreaRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const {
|
||||
workflowIds,
|
||||
folderIds,
|
||||
triggers,
|
||||
timeRange: sidebarTimeRange,
|
||||
level,
|
||||
searchQuery,
|
||||
} = useFilterStore()
|
||||
|
||||
const { workflows } = useWorkflowRegistry()
|
||||
|
||||
const timeFilter = getTimeFilterFromRange(sidebarTimeRange)
|
||||
|
||||
const getStartTime = useCallback(() => {
|
||||
const start = new Date(endTime)
|
||||
|
||||
switch (timeFilter) {
|
||||
case '30m':
|
||||
start.setMinutes(endTime.getMinutes() - 30)
|
||||
break
|
||||
case '1h':
|
||||
start.setHours(endTime.getHours() - 1)
|
||||
break
|
||||
case '6h':
|
||||
start.setHours(endTime.getHours() - 6)
|
||||
break
|
||||
case '12h':
|
||||
start.setHours(endTime.getHours() - 12)
|
||||
break
|
||||
case '24h':
|
||||
start.setHours(endTime.getHours() - 24)
|
||||
break
|
||||
case '3d':
|
||||
start.setDate(endTime.getDate() - 3)
|
||||
break
|
||||
case '7d':
|
||||
start.setDate(endTime.getDate() - 7)
|
||||
break
|
||||
case '14d':
|
||||
start.setDate(endTime.getDate() - 14)
|
||||
break
|
||||
case '30d':
|
||||
start.setDate(endTime.getDate() - 30)
|
||||
break
|
||||
default:
|
||||
start.setHours(endTime.getHours() - 24)
|
||||
}
|
||||
|
||||
return start
|
||||
}, [endTime, timeFilter])
|
||||
|
||||
const metricsFilters = useMemo(
|
||||
() => ({
|
||||
workspaceId,
|
||||
segments: segmentCount || DEFAULT_SEGMENTS,
|
||||
startTime: getStartTime().toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
workflowIds: workflowIds.length > 0 ? workflowIds : undefined,
|
||||
folderIds: folderIds.length > 0 ? folderIds : undefined,
|
||||
triggers: triggers.length > 0 ? triggers : undefined,
|
||||
level: level !== 'all' ? level : undefined,
|
||||
}),
|
||||
[workspaceId, segmentCount, getStartTime, endTime, workflowIds, folderIds, triggers, level]
|
||||
)
|
||||
|
||||
const logsFilters = useMemo(
|
||||
() => ({
|
||||
workspaceId,
|
||||
startDate: getStartTime().toISOString(),
|
||||
endDate: endTime.toISOString(),
|
||||
workflowIds: workflowIds.length > 0 ? workflowIds : undefined,
|
||||
folderIds: folderIds.length > 0 ? folderIds : undefined,
|
||||
triggers: triggers.length > 0 ? triggers : undefined,
|
||||
level: level !== 'all' ? level : undefined,
|
||||
searchQuery: searchQuery.trim() || undefined,
|
||||
limit: 50,
|
||||
}),
|
||||
[workspaceId, getStartTime, endTime, workflowIds, folderIds, triggers, level, searchQuery]
|
||||
)
|
||||
|
||||
const metricsQuery = useExecutionsMetrics(metricsFilters, {
|
||||
enabled: Boolean(workspaceId),
|
||||
})
|
||||
|
||||
const globalLogsQuery = useGlobalDashboardLogs(logsFilters, {
|
||||
enabled: Boolean(workspaceId),
|
||||
})
|
||||
|
||||
const workflowLogsQuery = useWorkflowDashboardLogs(expandedWorkflowId ?? undefined, logsFilters, {
|
||||
enabled: Boolean(workspaceId) && Boolean(expandedWorkflowId),
|
||||
})
|
||||
|
||||
const executions = metricsQuery.data?.workflows ?? []
|
||||
const aggregateSegments = metricsQuery.data?.aggregateSegments ?? []
|
||||
const loading = metricsQuery.isLoading
|
||||
const error = metricsQuery.error?.message ?? null
|
||||
|
||||
// Check if any filters are actually applied
|
||||
const hasActiveFilters = useMemo(
|
||||
() =>
|
||||
level !== 'all' ||
|
||||
workflowIds.length > 0 ||
|
||||
folderIds.length > 0 ||
|
||||
triggers.length > 0 ||
|
||||
searchQuery.trim() !== '',
|
||||
[level, workflowIds, folderIds, triggers, searchQuery]
|
||||
)
|
||||
|
||||
// Filter workflows based on search query and whether they have any executions matching the filters
|
||||
const filteredExecutions = useMemo(() => {
|
||||
let filtered = executions
|
||||
|
||||
// Only filter out workflows with no executions if filters are active
|
||||
if (hasActiveFilters) {
|
||||
filtered = filtered.filter((workflow) => {
|
||||
const hasExecutions = workflow.segments.some((seg) => seg.hasExecutions === true)
|
||||
return hasExecutions
|
||||
})
|
||||
}
|
||||
|
||||
// Apply search query filter
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase().trim()
|
||||
filtered = filtered.filter((workflow) => workflow.workflowName.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
// Sort by creation date (newest first) to match sidebar ordering
|
||||
filtered = filtered.sort((a, b) => {
|
||||
const workflowA = workflows[a.workflowId]
|
||||
const workflowB = workflows[b.workflowId]
|
||||
if (!workflowA || !workflowB) return 0
|
||||
return workflowB.createdAt.getTime() - workflowA.createdAt.getTime()
|
||||
})
|
||||
|
||||
return filtered
|
||||
}, [executions, searchQuery, hasActiveFilters, workflows])
|
||||
|
||||
const globalLogs = useMemo(() => {
|
||||
if (!globalLogsQuery.data?.pages) return []
|
||||
return globalLogsQuery.data.pages.flatMap((page) => page.logs).map(mapToExecutionLog)
|
||||
}, [globalLogsQuery.data?.pages])
|
||||
|
||||
const workflowLogs = useMemo(() => {
|
||||
if (!workflowLogsQuery.data?.pages) return []
|
||||
return workflowLogsQuery.data.pages.flatMap((page) => page.logs).map(mapToExecutionLogAlt)
|
||||
}, [workflowLogsQuery.data?.pages])
|
||||
|
||||
const globalDetails = useMemo(() => {
|
||||
if (!aggregateSegments.length) return null
|
||||
|
||||
const hasSelection = Object.keys(selectedSegments).length > 0
|
||||
const hasWorkflowFilter = expandedWorkflowId && expandedWorkflowId !== '__multi__'
|
||||
|
||||
// Stack filters: workflow filter + segment selection
|
||||
const segmentsToUse = hasSelection
|
||||
? (() => {
|
||||
// Get all selected segment indices across all workflows
|
||||
const allSelectedIndices = new Set<number>()
|
||||
Object.values(selectedSegments).forEach((indices) => {
|
||||
indices.forEach((idx) => allSelectedIndices.add(idx))
|
||||
})
|
||||
|
||||
// For each selected index, aggregate data from workflows that have that segment selected
|
||||
// If a workflow filter is active, only include that workflow's data
|
||||
return Array.from(allSelectedIndices)
|
||||
.sort((a, b) => a - b)
|
||||
.map((idx) => {
|
||||
let totalExecutions = 0
|
||||
let successfulExecutions = 0
|
||||
let weightedLatencySum = 0
|
||||
let latencyCount = 0
|
||||
const timestamp = aggregateSegments[idx]?.timestamp || ''
|
||||
|
||||
// Sum up data from workflows that have this segment selected
|
||||
Object.entries(selectedSegments).forEach(([workflowId, indices]) => {
|
||||
if (!indices.includes(idx)) return
|
||||
|
||||
// If workflow filter is active, skip other workflows
|
||||
if (hasWorkflowFilter && workflowId !== expandedWorkflowId) return
|
||||
|
||||
const workflow = filteredExecutions.find((w) => w.workflowId === workflowId)
|
||||
const segment = workflow?.segments[idx]
|
||||
if (!segment) return
|
||||
|
||||
totalExecutions += segment.totalExecutions || 0
|
||||
successfulExecutions += segment.successfulExecutions || 0
|
||||
|
||||
if (segment.avgDurationMs && segment.totalExecutions) {
|
||||
weightedLatencySum += segment.avgDurationMs * segment.totalExecutions
|
||||
latencyCount += segment.totalExecutions
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
timestamp,
|
||||
totalExecutions,
|
||||
successfulExecutions,
|
||||
avgDurationMs: latencyCount > 0 ? weightedLatencySum / latencyCount : 0,
|
||||
}
|
||||
})
|
||||
})()
|
||||
: hasWorkflowFilter
|
||||
? (() => {
|
||||
// Filter to show only the expanded workflow's data
|
||||
const workflow = filteredExecutions.find((w) => w.workflowId === expandedWorkflowId)
|
||||
if (!workflow) return aggregateSegments
|
||||
|
||||
return workflow.segments.map((segment) => ({
|
||||
timestamp: segment.timestamp,
|
||||
totalExecutions: segment.totalExecutions || 0,
|
||||
successfulExecutions: segment.successfulExecutions || 0,
|
||||
avgDurationMs: segment.avgDurationMs ?? 0,
|
||||
}))
|
||||
})()
|
||||
: hasActiveFilters
|
||||
? (() => {
|
||||
// Always recalculate aggregate segments based on filtered workflows when filters are active
|
||||
return aggregateSegments.map((aggSeg, idx) => {
|
||||
let totalExecutions = 0
|
||||
let successfulExecutions = 0
|
||||
let weightedLatencySum = 0
|
||||
let latencyCount = 0
|
||||
|
||||
filteredExecutions.forEach((workflow) => {
|
||||
const segment = workflow.segments[idx]
|
||||
if (!segment) return
|
||||
|
||||
totalExecutions += segment.totalExecutions || 0
|
||||
successfulExecutions += segment.successfulExecutions || 0
|
||||
|
||||
if (segment.avgDurationMs && segment.totalExecutions) {
|
||||
weightedLatencySum += segment.avgDurationMs * segment.totalExecutions
|
||||
latencyCount += segment.totalExecutions
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
timestamp: aggSeg.timestamp,
|
||||
totalExecutions,
|
||||
successfulExecutions,
|
||||
avgDurationMs: latencyCount > 0 ? weightedLatencySum / latencyCount : 0,
|
||||
}
|
||||
})
|
||||
})()
|
||||
: aggregateSegments
|
||||
|
||||
const errorRates = segmentsToUse.map((s) => ({
|
||||
timestamp: s.timestamp,
|
||||
value: s.totalExecutions > 0 ? (1 - s.successfulExecutions / s.totalExecutions) * 100 : 0,
|
||||
}))
|
||||
|
||||
const executionCounts = segmentsToUse.map((s) => ({
|
||||
timestamp: s.timestamp,
|
||||
value: s.totalExecutions,
|
||||
}))
|
||||
|
||||
const failureCounts = segmentsToUse.map((s) => ({
|
||||
timestamp: s.timestamp,
|
||||
value: s.totalExecutions - s.successfulExecutions,
|
||||
}))
|
||||
|
||||
const latencies = segmentsToUse.map((s) => ({
|
||||
timestamp: s.timestamp,
|
||||
value: s.avgDurationMs ?? 0,
|
||||
}))
|
||||
|
||||
return {
|
||||
errorRates,
|
||||
durations: [],
|
||||
executionCounts,
|
||||
failureCounts,
|
||||
latencies,
|
||||
logs: globalLogs,
|
||||
allLogs: globalLogs,
|
||||
}
|
||||
}, [
|
||||
aggregateSegments,
|
||||
globalLogs,
|
||||
selectedSegments,
|
||||
filteredExecutions,
|
||||
expandedWorkflowId,
|
||||
hasActiveFilters,
|
||||
])
|
||||
|
||||
const workflowDetails = useMemo(() => {
|
||||
if (!expandedWorkflowId || !workflowLogs.length) return {}
|
||||
|
||||
return {
|
||||
[expandedWorkflowId]: {
|
||||
errorRates: [],
|
||||
durations: [],
|
||||
executionCounts: [],
|
||||
logs: workflowLogs,
|
||||
allLogs: workflowLogs,
|
||||
},
|
||||
}
|
||||
}, [expandedWorkflowId, workflowLogs])
|
||||
|
||||
const aggregate = useMemo(() => {
|
||||
const hasSelection = Object.keys(selectedSegments).length > 0
|
||||
const hasWorkflowFilter = expandedWorkflowId && expandedWorkflowId !== '__multi__'
|
||||
let totalExecutions = 0
|
||||
let successfulExecutions = 0
|
||||
let activeWorkflows = 0
|
||||
let weightedLatencySum = 0
|
||||
let latencyExecutionCount = 0
|
||||
|
||||
// Apply workflow filter first if present, otherwise use filtered executions
|
||||
const workflowsToProcess = hasWorkflowFilter
|
||||
? filteredExecutions.filter((wf) => wf.workflowId === expandedWorkflowId)
|
||||
: filteredExecutions
|
||||
|
||||
for (const wf of workflowsToProcess) {
|
||||
const selectedIndices = hasSelection ? selectedSegments[wf.workflowId] : null
|
||||
let workflowHasExecutions = false
|
||||
|
||||
wf.segments.forEach((seg, idx) => {
|
||||
// If segment selection exists, only count selected segments
|
||||
// Otherwise, count all segments
|
||||
if (!selectedIndices || selectedIndices.includes(idx)) {
|
||||
const execCount = seg.totalExecutions || 0
|
||||
totalExecutions += execCount
|
||||
successfulExecutions += seg.successfulExecutions || 0
|
||||
|
||||
if (
|
||||
seg.avgDurationMs !== undefined &&
|
||||
seg.avgDurationMs !== null &&
|
||||
seg.avgDurationMs > 0 &&
|
||||
execCount > 0
|
||||
) {
|
||||
weightedLatencySum += seg.avgDurationMs * execCount
|
||||
latencyExecutionCount += execCount
|
||||
}
|
||||
if (seg.hasExecutions) workflowHasExecutions = true
|
||||
}
|
||||
})
|
||||
|
||||
if (workflowHasExecutions) activeWorkflows += 1
|
||||
}
|
||||
|
||||
const failedExecutions = Math.max(totalExecutions - successfulExecutions, 0)
|
||||
const successRate = totalExecutions > 0 ? (successfulExecutions / totalExecutions) * 100 : 100
|
||||
const avgLatency = latencyExecutionCount > 0 ? weightedLatencySum / latencyExecutionCount : 0
|
||||
|
||||
return {
|
||||
totalExecutions,
|
||||
successfulExecutions,
|
||||
failedExecutions,
|
||||
activeWorkflows,
|
||||
successRate,
|
||||
avgLatency,
|
||||
}
|
||||
}, [filteredExecutions, selectedSegments, expandedWorkflowId])
|
||||
|
||||
const loadMoreLogs = useCallback(
|
||||
(workflowId: string) => {
|
||||
if (
|
||||
workflowId === expandedWorkflowId &&
|
||||
workflowLogsQuery.hasNextPage &&
|
||||
!workflowLogsQuery.isFetchingNextPage
|
||||
) {
|
||||
workflowLogsQuery.fetchNextPage()
|
||||
}
|
||||
},
|
||||
[expandedWorkflowId, workflowLogsQuery]
|
||||
)
|
||||
|
||||
const loadMoreGlobalLogs = useCallback(() => {
|
||||
if (globalLogsQuery.hasNextPage && !globalLogsQuery.isFetchingNextPage) {
|
||||
globalLogsQuery.fetchNextPage()
|
||||
}
|
||||
}, [globalLogsQuery])
|
||||
|
||||
const toggleWorkflow = useCallback(
|
||||
(workflowId: string) => {
|
||||
if (expandedWorkflowId === workflowId) {
|
||||
setExpandedWorkflowId(null)
|
||||
setSelectedSegments({})
|
||||
setLastAnchorIndices({})
|
||||
} else {
|
||||
setExpandedWorkflowId(workflowId)
|
||||
setSelectedSegments({})
|
||||
setLastAnchorIndices({})
|
||||
}
|
||||
},
|
||||
[expandedWorkflowId]
|
||||
)
|
||||
|
||||
const handleSegmentClick = useCallback(
|
||||
(
|
||||
workflowId: string,
|
||||
segmentIndex: number,
|
||||
_timestamp: string,
|
||||
mode: 'single' | 'toggle' | 'range'
|
||||
) => {
|
||||
if (mode === 'toggle') {
|
||||
setSelectedSegments((prev) => {
|
||||
const currentSegments = prev[workflowId] || []
|
||||
const exists = currentSegments.includes(segmentIndex)
|
||||
const nextSegments = exists
|
||||
? currentSegments.filter((i) => i !== segmentIndex)
|
||||
: [...currentSegments, segmentIndex].sort((a, b) => a - b)
|
||||
|
||||
if (nextSegments.length === 0) {
|
||||
const { [workflowId]: _, ...rest } = prev
|
||||
if (Object.keys(rest).length === 0) {
|
||||
setExpandedWorkflowId(null)
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
const newState = { ...prev, [workflowId]: nextSegments }
|
||||
|
||||
const selectedWorkflowIds = Object.keys(newState)
|
||||
if (selectedWorkflowIds.length > 1) {
|
||||
setExpandedWorkflowId('__multi__')
|
||||
} else if (selectedWorkflowIds.length === 1) {
|
||||
setExpandedWorkflowId(selectedWorkflowIds[0])
|
||||
}
|
||||
|
||||
return newState
|
||||
})
|
||||
|
||||
setLastAnchorIndices((prev) => ({ ...prev, [workflowId]: segmentIndex }))
|
||||
} else if (mode === 'single') {
|
||||
setSelectedSegments((prev) => {
|
||||
const currentSegments = prev[workflowId] || []
|
||||
const isOnlySelectedSegment =
|
||||
currentSegments.length === 1 && currentSegments[0] === segmentIndex
|
||||
const isOnlyWorkflowSelected = Object.keys(prev).length === 1 && prev[workflowId]
|
||||
|
||||
if (isOnlySelectedSegment && isOnlyWorkflowSelected) {
|
||||
setExpandedWorkflowId(null)
|
||||
setLastAnchorIndices({})
|
||||
return {}
|
||||
}
|
||||
|
||||
setExpandedWorkflowId(workflowId)
|
||||
setLastAnchorIndices({ [workflowId]: segmentIndex })
|
||||
return { [workflowId]: [segmentIndex] }
|
||||
})
|
||||
} else if (mode === 'range') {
|
||||
if (expandedWorkflowId === workflowId) {
|
||||
setSelectedSegments((prev) => {
|
||||
const currentSegments = prev[workflowId] || []
|
||||
const anchor = lastAnchorIndices[workflowId] ?? segmentIndex
|
||||
const [start, end] =
|
||||
anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor]
|
||||
const range = Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||
const union = new Set([...currentSegments, ...range])
|
||||
return { ...prev, [workflowId]: Array.from(union).sort((a, b) => a - b) }
|
||||
})
|
||||
} else {
|
||||
setExpandedWorkflowId(workflowId)
|
||||
setSelectedSegments({ [workflowId]: [segmentIndex] })
|
||||
setLastAnchorIndices({ [workflowId]: segmentIndex })
|
||||
}
|
||||
}
|
||||
},
|
||||
[expandedWorkflowId, lastAnchorIndices]
|
||||
)
|
||||
|
||||
// Update endTime when filters change to ensure consistent time ranges with logs view
|
||||
useEffect(() => {
|
||||
setEndTime(new Date())
|
||||
setSelectedSegments({})
|
||||
setLastAnchorIndices({})
|
||||
}, [timeFilter, workflowIds, folderIds, triggers, level, searchQuery])
|
||||
|
||||
// Clear expanded workflow if it's no longer in filtered executions
|
||||
useEffect(() => {
|
||||
if (expandedWorkflowId && expandedWorkflowId !== '__multi__') {
|
||||
const isStillVisible = filteredExecutions.some((wf) => wf.workflowId === expandedWorkflowId)
|
||||
if (!isStillVisible) {
|
||||
setExpandedWorkflowId(null)
|
||||
setSelectedSegments({})
|
||||
setLastAnchorIndices({})
|
||||
}
|
||||
} else if (expandedWorkflowId === '__multi__') {
|
||||
// Check if any of the selected workflows are still visible
|
||||
const selectedWorkflowIds = Object.keys(selectedSegments)
|
||||
const stillVisibleIds = selectedWorkflowIds.filter((id) =>
|
||||
filteredExecutions.some((wf) => wf.workflowId === id)
|
||||
)
|
||||
|
||||
if (stillVisibleIds.length === 0) {
|
||||
setExpandedWorkflowId(null)
|
||||
setSelectedSegments({})
|
||||
setLastAnchorIndices({})
|
||||
} else if (stillVisibleIds.length !== selectedWorkflowIds.length) {
|
||||
// Remove segments for workflows that are no longer visible
|
||||
const updatedSegments: Record<string, number[]> = {}
|
||||
stillVisibleIds.forEach((id) => {
|
||||
if (selectedSegments[id]) {
|
||||
updatedSegments[id] = selectedSegments[id]
|
||||
}
|
||||
})
|
||||
setSelectedSegments(updatedSegments)
|
||||
|
||||
if (stillVisibleIds.length === 1) {
|
||||
setExpandedWorkflowId(stillVisibleIds[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [filteredExecutions, expandedWorkflowId, selectedSegments])
|
||||
|
||||
// Notify parent when custom time range is active
|
||||
useEffect(() => {
|
||||
const hasCustomRange = Object.keys(selectedSegments).length > 0
|
||||
onCustomTimeRangeChange?.(hasCustomRange)
|
||||
}, [selectedSegments, onCustomTimeRangeChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (!barsAreaRef.current) return
|
||||
const el = barsAreaRef.current
|
||||
let debounceId: ReturnType<typeof setTimeout> | null = null
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
const w = entry?.contentRect?.width || 720
|
||||
const n = Math.max(36, Math.min(96, Math.floor(w / MIN_SEGMENT_PX)))
|
||||
if (debounceId) clearTimeout(debounceId)
|
||||
debounceId = setTimeout(() => {
|
||||
setSegmentCount(n)
|
||||
}, 150)
|
||||
})
|
||||
ro.observe(el)
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect?.width) {
|
||||
const n = Math.max(36, Math.min(96, Math.floor(rect.width / MIN_SEGMENT_PX)))
|
||||
setSegmentCount(n)
|
||||
}
|
||||
return () => {
|
||||
if (debounceId) clearTimeout(debounceId)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Live mode: refresh endTime periodically
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
const interval = setInterval(() => {
|
||||
setEndTime(new Date())
|
||||
}, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [isLive])
|
||||
|
||||
// Refresh when trigger changes
|
||||
useEffect(() => {
|
||||
if (refreshTrigger > 0) {
|
||||
setEndTime(new Date())
|
||||
}
|
||||
}, [refreshTrigger])
|
||||
|
||||
if (loading) {
|
||||
return <DashboardSkeleton />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='mt-[24px] flex flex-1 items-center justify-center'>
|
||||
<div className='text-[var(--text-error)]'>
|
||||
<p className='font-medium text-[13px]'>Error loading data</p>
|
||||
<p className='text-[12px]'>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (executions.length === 0) {
|
||||
return (
|
||||
<div className='mt-[24px] flex flex-1 items-center justify-center'>
|
||||
<div className='text-center text-[var(--text-secondary)]'>
|
||||
<p className='font-medium text-[13px]'>No execution history</p>
|
||||
<p className='mt-[4px] text-[12px]'>Execute some workflows to see their history here</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-[24px] flex min-h-0 flex-1 flex-col'>
|
||||
{/* Graphs Section */}
|
||||
<div className='mb-[16px] flex-shrink-0'>
|
||||
<div className='grid grid-cols-1 gap-[16px] md:grid-cols-3'>
|
||||
{/* Runs Graph */}
|
||||
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
|
||||
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
|
||||
Runs
|
||||
</span>
|
||||
{globalDetails && globalDetails.executionCounts.length > 0 && (
|
||||
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
|
||||
{aggregate.totalExecutions}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-1)] px-[14px] py-[10px]'>
|
||||
{globalDetails ? (
|
||||
<LineChart
|
||||
key={`runs-${expandedWorkflowId || 'all'}-${Object.keys(selectedSegments).length}-${filteredExecutions.length}`}
|
||||
data={globalDetails.executionCounts}
|
||||
label=''
|
||||
color='var(--brand-tertiary)'
|
||||
unit=''
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-[166px] items-center justify-center'>
|
||||
<Loader2 className='h-[16px] w-[16px] animate-spin text-[var(--text-secondary)]' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Errors Graph */}
|
||||
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
|
||||
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
|
||||
Errors
|
||||
</span>
|
||||
{globalDetails && globalDetails.failureCounts.length > 0 && (
|
||||
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
|
||||
{aggregate.failedExecutions}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-1)] px-[14px] py-[10px]'>
|
||||
{globalDetails ? (
|
||||
<LineChart
|
||||
key={`errors-${expandedWorkflowId || 'all'}-${Object.keys(selectedSegments).length}-${filteredExecutions.length}`}
|
||||
data={globalDetails.failureCounts}
|
||||
label=''
|
||||
color='var(--text-error)'
|
||||
unit=''
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-[166px] items-center justify-center'>
|
||||
<Loader2 className='h-[16px] w-[16px] animate-spin text-[var(--text-secondary)]' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Latency Graph */}
|
||||
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
|
||||
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
|
||||
Latency
|
||||
</span>
|
||||
{globalDetails && globalDetails.latencies.length > 0 && (
|
||||
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
|
||||
{formatLatency(aggregate.avgLatency)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-1)] px-[14px] py-[10px]'>
|
||||
{globalDetails ? (
|
||||
<LineChart
|
||||
key={`latency-${expandedWorkflowId || 'all'}-${Object.keys(selectedSegments).length}-${filteredExecutions.length}`}
|
||||
data={globalDetails.latencies}
|
||||
label=''
|
||||
color='var(--c-F59E0B)'
|
||||
unit='latency'
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-[166px] items-center justify-center'>
|
||||
<Loader2 className='h-[16px] w-[16px] animate-spin text-[var(--text-secondary)]' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflows Table - takes remaining space */}
|
||||
<div className='min-h-0 flex-1 overflow-hidden' ref={barsAreaRef}>
|
||||
<WorkflowsList
|
||||
executions={executions as WorkflowExecution[]}
|
||||
filteredExecutions={filteredExecutions as WorkflowExecution[]}
|
||||
expandedWorkflowId={expandedWorkflowId}
|
||||
onToggleWorkflow={toggleWorkflow}
|
||||
selectedSegments={selectedSegments}
|
||||
onSegmentClick={handleSegmentClick}
|
||||
searchQuery={searchQuery}
|
||||
segmentDurationMs={
|
||||
(endTime.getTime() - getStartTime().getTime()) / Math.max(1, segmentCount)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default, default as Dashboard } from './dashboard'
|
||||
@@ -1,38 +0,0 @@
|
||||
export interface AggregateMetrics {
|
||||
totalExecutions: number
|
||||
successfulExecutions: number
|
||||
failedExecutions: number
|
||||
activeWorkflows: number
|
||||
successRate: number
|
||||
}
|
||||
|
||||
export function KPIs({ aggregate }: { aggregate: AggregateMetrics }) {
|
||||
return (
|
||||
<div className='mb-2 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4'>
|
||||
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
|
||||
<div className='text-muted-foreground text-xs'>Total executions</div>
|
||||
<div className='mt-1 font-[440] text-[22px] leading-6'>
|
||||
{aggregate.totalExecutions.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
|
||||
<div className='text-muted-foreground text-xs'>Success rate</div>
|
||||
<div className='mt-1 font-[440] text-[22px] leading-6'>
|
||||
{aggregate.successRate.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
|
||||
<div className='text-muted-foreground text-xs'>Failed executions</div>
|
||||
<div className='mt-1 font-[440] text-[22px] leading-6'>
|
||||
{aggregate.failedExecutions.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
|
||||
<div className='text-muted-foreground text-xs'>Active workflows</div>
|
||||
<div className='mt-1 font-[440] text-[22px] leading-6'>{aggregate.activeWorkflows}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default KPIs
|
||||
@@ -1,17 +0,0 @@
|
||||
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'
|
||||
}
|
||||
}
|
||||
@@ -1,624 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowUpRight, Info, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { highlight, languages } from 'prismjs'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/components/prism-python'
|
||||
import 'prismjs/components/prism-json'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import LineChart, {
|
||||
type LineChartPoint,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components/dashboard/line-chart'
|
||||
import { getTriggerColor } from '@/app/workspace/[workspaceId]/logs/components/dashboard/utils'
|
||||
import LogMarkdownRenderer from '@/app/workspace/[workspaceId]/logs/components/sidebar/components/markdown-renderer'
|
||||
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import '@/components/emcn/components/code/code.css'
|
||||
|
||||
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
|
||||
hasPendingPause?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to parse a string as JSON and prettify it
|
||||
*/
|
||||
const tryPrettifyJson = (content: string): { isJson: boolean; formatted: string } => {
|
||||
try {
|
||||
const trimmed = content.trim()
|
||||
if (
|
||||
!(trimmed.startsWith('{') || trimmed.startsWith('[')) ||
|
||||
!(trimmed.endsWith('}') || trimmed.endsWith(']'))
|
||||
) {
|
||||
return { isJson: false, formatted: content }
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(trimmed)
|
||||
const prettified = JSON.stringify(parsed, null, 2)
|
||||
return { isJson: true, formatted: prettified }
|
||||
} catch (_e) {
|
||||
return { isJson: false, formatted: content }
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkflowDetailsData {
|
||||
errorRates: LineChartPoint[]
|
||||
durations?: LineChartPoint[]
|
||||
durationP50?: LineChartPoint[]
|
||||
durationP90?: LineChartPoint[]
|
||||
durationP99?: LineChartPoint[]
|
||||
executionCounts: LineChartPoint[]
|
||||
logs: ExecutionLogItem[]
|
||||
allLogs: ExecutionLogItem[]
|
||||
}
|
||||
|
||||
export function WorkflowDetails({
|
||||
workspaceId,
|
||||
expandedWorkflowId,
|
||||
workflowName,
|
||||
overview,
|
||||
details,
|
||||
selectedSegmentIndex,
|
||||
selectedSegment,
|
||||
selectedSegmentTimeRange,
|
||||
selectedWorkflowNames,
|
||||
segmentDurationMs,
|
||||
clearSegmentSelection,
|
||||
formatCost,
|
||||
onLoadMore,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
}: {
|
||||
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
|
||||
selectedSegmentTimeRange?: { start: Date; end: Date } | null
|
||||
selectedWorkflowNames?: string[]
|
||||
segmentDurationMs?: number
|
||||
clearSegmentSelection: () => void
|
||||
formatCost: (n: number) => string
|
||||
onLoadMore?: () => void
|
||||
hasMore?: boolean
|
||||
isLoadingMore?: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const { workflows } = useWorkflowRegistry()
|
||||
|
||||
// Check if any logs have pending status to show Resume column
|
||||
const hasPendingExecutions = useMemo(() => {
|
||||
return details?.logs?.some((log) => log.hasPendingPause === true) || false
|
||||
}, [details])
|
||||
|
||||
const workflowColor = useMemo(
|
||||
() => workflows[expandedWorkflowId]?.color || '#3972F6',
|
||||
[workflows, expandedWorkflowId]
|
||||
)
|
||||
const [expandedRowId, setExpandedRowId] = useState<string | null>(null)
|
||||
const listRef = useRef<HTMLDivElement | null>(null)
|
||||
const loaderRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const rootEl = listRef.current
|
||||
const sentinel = loaderRef.current
|
||||
if (!rootEl || !sentinel || !onLoadMore || !hasMore) return
|
||||
|
||||
let ticking = false
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry?.isIntersecting && hasMore && !ticking && !isLoadingMore) {
|
||||
ticking = true
|
||||
setTimeout(() => {
|
||||
onLoadMore()
|
||||
ticking = false
|
||||
}, 50)
|
||||
}
|
||||
},
|
||||
{ root: rootEl, threshold: 0.1, rootMargin: '200px 0px 0px 0px' }
|
||||
)
|
||||
|
||||
observer.observe(sentinel)
|
||||
return () => observer.disconnect()
|
||||
}, [onLoadMore, hasMore, isLoadingMore])
|
||||
|
||||
// Fallback: if IntersectionObserver fails (older browsers), use scroll position
|
||||
useEffect(() => {
|
||||
const el = listRef.current
|
||||
if (!el || !onLoadMore || !hasMore) return
|
||||
|
||||
const onScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = el
|
||||
const pct = (scrollTop / Math.max(1, scrollHeight - clientHeight)) * 100
|
||||
if (pct > 80 && !isLoadingMore) onLoadMore()
|
||||
}
|
||||
el.addEventListener('scroll', onScroll)
|
||||
return () => el.removeEventListener('scroll', onScroll)
|
||||
}, [onLoadMore, hasMore, isLoadingMore])
|
||||
|
||||
return (
|
||||
<div className='mt-1 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'>
|
||||
{expandedWorkflowId !== 'all' && expandedWorkflowId !== '__multi__' ? (
|
||||
<button
|
||||
onClick={() => router.push(`/workspace/${workspaceId}/w/${expandedWorkflowId}`)}
|
||||
className='group inline-flex items-center gap-2 text-left transition-opacity hover:opacity-70'
|
||||
>
|
||||
<span
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded'
|
||||
style={{ backgroundColor: workflowColor }}
|
||||
/>
|
||||
<span className='font-[480] text-sm tracking-tight dark:font-[560]'>
|
||||
{workflowName}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className='inline-flex items-center gap-2'>
|
||||
<span
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded'
|
||||
style={{ backgroundColor: workflowColor }}
|
||||
/>
|
||||
<span className='font-[480] text-sm tracking-tight dark:font-[560]'>
|
||||
{workflowName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(selectedSegmentIndex) &&
|
||||
selectedSegmentIndex.length > 0 &&
|
||||
(selectedSegment || selectedSegmentTimeRange || expandedWorkflowId === '__multi__') &&
|
||||
(() => {
|
||||
let tsLabel = 'Selected segment'
|
||||
if (selectedSegmentTimeRange) {
|
||||
const start = selectedSegmentTimeRange.start
|
||||
const end = selectedSegmentTimeRange.end
|
||||
const startFormatted = start.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
const endFormatted = end.toLocaleString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
tsLabel = `${startFormatted} – ${endFormatted}`
|
||||
} else if (selectedSegment?.timestamp) {
|
||||
const tsObj = new Date(selectedSegment.timestamp)
|
||||
if (!Number.isNaN(tsObj.getTime())) {
|
||||
tsLabel = tsObj.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isMultiWorkflow =
|
||||
expandedWorkflowId === '__multi__' &&
|
||||
selectedWorkflowNames &&
|
||||
selectedWorkflowNames.length > 0
|
||||
const workflowLabel = isMultiWorkflow
|
||||
? selectedWorkflowNames.length <= 2
|
||||
? selectedWorkflowNames.join(', ')
|
||||
: `${selectedWorkflowNames.slice(0, 2).join(', ')} +${selectedWorkflowNames.length - 2}`
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className='inline-flex h-7 items-center gap-1.5 rounded-md border bg-muted/50 px-2.5'>
|
||||
{isMultiWorkflow && workflowLabel && (
|
||||
<span className='font-medium text-[11px] text-muted-foreground'>
|
||||
{workflowLabel}
|
||||
</span>
|
||||
)}
|
||||
<span className='font-medium text-[11px] text-foreground'>
|
||||
{tsLabel}
|
||||
{selectedSegmentIndex.length > 1 && !isMultiWorkflow
|
||||
? ` (+${selectedSegmentIndex.length - 1})`
|
||||
: ''}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
clearSegmentSelection()
|
||||
}}
|
||||
className='ml-0.5 flex h-4 w-4 items-center justify-center rounded text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-1 focus:ring-primary/40'
|
||||
aria-label='Clear filter'
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='inline-flex h-7 items-center gap-2 rounded border px-2.5'>
|
||||
<span className='text-[11px] text-muted-foreground'>Executions</span>
|
||||
<span className='font-[500] text-sm leading-none'>{overview.total}</span>
|
||||
</div>
|
||||
<div className='inline-flex h-7 items-center gap-2 rounded border px-2.5'>
|
||||
<span className='text-[11px] text-muted-foreground'>Success</span>
|
||||
<span className='font-[500] text-sm leading-none'>{overview.rate.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className='inline-flex h-7 items-center gap-2 rounded border px-2.5'>
|
||||
<span className='text-[11px] text-muted-foreground'>Failures</span>
|
||||
<span className='font-[500] text-sm leading-none'>{overview.failures}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='p-4'>
|
||||
{details ? (
|
||||
<>
|
||||
{(() => {
|
||||
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'
|
||||
const gridGap = hasDuration ? 'gap-2 xl:gap-2.5' : 'gap-3'
|
||||
return (
|
||||
<div className={`mb-3 grid grid-cols-1 ${gridGap} ${gridCols}`}>
|
||||
<LineChart
|
||||
data={details.errorRates}
|
||||
label='Error Rate'
|
||||
color='var(--text-error)'
|
||||
unit='%'
|
||||
/>
|
||||
{hasDuration && (
|
||||
<LineChart
|
||||
data={details.durations!}
|
||||
label='Duration'
|
||||
color='#3b82f6'
|
||||
unit='ms'
|
||||
series={
|
||||
[
|
||||
details.durationP50
|
||||
? {
|
||||
id: 'p50',
|
||||
label: 'p50',
|
||||
color: '#60A5FA',
|
||||
data: details.durationP50,
|
||||
dashed: true,
|
||||
}
|
||||
: undefined,
|
||||
details.durationP90
|
||||
? {
|
||||
id: 'p90',
|
||||
label: 'p90',
|
||||
color: '#3B82F6',
|
||||
data: details.durationP90,
|
||||
}
|
||||
: undefined,
|
||||
details.durationP99
|
||||
? {
|
||||
id: 'p99',
|
||||
label: 'p99',
|
||||
color: '#1D4ED8',
|
||||
data: details.durationP99,
|
||||
}
|
||||
: undefined,
|
||||
].filter(Boolean) as any
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<LineChart
|
||||
data={details.executionCounts}
|
||||
label='Executions'
|
||||
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-b-0'>
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-w-[980px] gap-2 px-2 pb-3 md:gap-3 lg:min-w-0 lg:gap-4',
|
||||
hasPendingExecutions
|
||||
? 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px_40px]'
|
||||
: 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px]'
|
||||
)}
|
||||
>
|
||||
<div className='font-[460] font-sans text-[13px] text-muted-foreground leading-normal'>
|
||||
Time
|
||||
</div>
|
||||
<div className='font-[460] font-sans text-[13px] text-muted-foreground leading-normal'>
|
||||
Status
|
||||
</div>
|
||||
<div className='font-[460] 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>
|
||||
{hasPendingExecutions && (
|
||||
<div className='text-right font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
|
||||
Resume
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} 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
|
||||
const baseLevel = (log.level || 'info').toLowerCase()
|
||||
const isPending = log.hasPendingPause === true
|
||||
const isError = baseLevel === 'error'
|
||||
const statusLabel = isPending
|
||||
? 'Pending'
|
||||
: `${baseLevel.charAt(0).toUpperCase()}${baseLevel.slice(1)}`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={log.id}
|
||||
className={cn(
|
||||
'cursor-pointer transition-all duration-200',
|
||||
isExpanded ? 'bg-accent/30' : 'hover:bg-accent/20'
|
||||
)}
|
||||
onClick={() =>
|
||||
setExpandedRowId((prev) => (prev === log.id ? null : log.id))
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-w-[980px] items-center gap-2 px-2 py-3 md:gap-3 lg:min-w-0 lg:gap-4',
|
||||
hasPendingExecutions
|
||||
? 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px_40px]'
|
||||
: 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px]'
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className='text-[13px]'>
|
||||
<span className='font-sm text-muted-foreground'>
|
||||
{formattedDate.compactDate}
|
||||
</span>
|
||||
<span
|
||||
style={{ marginLeft: '8px' }}
|
||||
className='hidden font-[400] sm:inline'
|
||||
>
|
||||
{formattedDate.compactTime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isError || !isPending ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[24px] w-[56px] items-center justify-start rounded-[6px] border pl-[9px]',
|
||||
isError
|
||||
? 'gap-[5px] border-[#883827] bg-[#491515]'
|
||||
: 'gap-[8px] border-[#686868] bg-[#383838]'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='h-[6px] w-[6px] rounded-[2px]'
|
||||
style={{
|
||||
backgroundColor: isError ? 'var(--text-error)' : '#B7B7B7',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className='font-medium text-[11.5px]'
|
||||
style={{ color: isError ? 'var(--text-error)' : '#B7B7B7' }}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='inline-flex items-center bg-amber-300 px-[6px] py-[2px] font-[400] text-amber-900 text-xs dark:bg-amber-500/90 dark:text-black'>
|
||||
{statusLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{log.trigger ? (
|
||||
<div
|
||||
className='inline-flex items-center rounded-[6px] px-[6px] py-[2px] font-[400] text-white text-xs lg:px-[8px]'
|
||||
style={{ backgroundColor: getTriggerColor(log.trigger) }}
|
||||
>
|
||||
{log.trigger}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-muted-foreground text-xs'>—</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='font-[400] 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 flex-shrink-0 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>
|
||||
|
||||
{hasPendingExecutions && (
|
||||
<div className='flex justify-end'>
|
||||
{isPending && log.executionId ? (
|
||||
<Link
|
||||
href={`/resume/${expandedWorkflowId}/${log.executionId}`}
|
||||
className='inline-flex h-7 w-7 items-center justify-center border border-primary/60 border-dashed text-primary hover:bg-primary/10'
|
||||
aria-label='Open resume console'
|
||||
>
|
||||
<ArrowUpRight className='h-4 w-4' />
|
||||
</Link>
|
||||
) : (
|
||||
<span className='h-7 w-7' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className='px-2 pt-0 pb-4'>
|
||||
<div className='group relative w-full rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-3'>
|
||||
<CopyButton
|
||||
text={log.level === 'error' && errorStr ? errorStr : outputsStr}
|
||||
className='z-10 h-7 w-7'
|
||||
/>
|
||||
{(() => {
|
||||
const content =
|
||||
log.level === 'error' && errorStr ? errorStr : outputsStr
|
||||
const { isJson, formatted } = tryPrettifyJson(content)
|
||||
|
||||
return isJson ? (
|
||||
<div className='code-editor-theme'>
|
||||
<pre
|
||||
className='max-h-[300px] w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(formatted, languages.json, 'json'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='max-h-[300px] overflow-y-auto'>
|
||||
<LogMarkdownRenderer content={formatted} />
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
{/* Bottom loading / sentinel */}
|
||||
{hasMore && details.logs.length > 0 && (
|
||||
<div className='flex items-center justify-center py-3 text-muted-foreground'>
|
||||
<div ref={loaderRef} className='flex items-center gap-2'>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
<span className='text-sm'>Loading more…</span>
|
||||
</>
|
||||
) : (
|
||||
<span className='text-sm'>Scroll to load more</span>
|
||||
)}
|
||||
</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
|
||||
@@ -1,137 +0,0 @@
|
||||
import { memo, 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,
|
||||
selectedSegments,
|
||||
onSegmentClick,
|
||||
searchQuery,
|
||||
segmentDurationMs,
|
||||
}: {
|
||||
executions: WorkflowExecutionItem[]
|
||||
filteredExecutions: WorkflowExecutionItem[]
|
||||
expandedWorkflowId: string | null
|
||||
onToggleWorkflow: (workflowId: string) => void
|
||||
selectedSegments: Record<string, number[]>
|
||||
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])
|
||||
|
||||
// Date axis above the status bars intentionally removed for a cleaner, denser layout
|
||||
|
||||
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-[11px] border bg-card shadow-sm'
|
||||
style={{ height: '380px', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<div className='flex-shrink-0 border-b bg-muted/30 px-4 py-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h3 className='font-[480] 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 removed */}
|
||||
<ScrollArea className='min-h-0 flex-1 overflow-auto'>
|
||||
<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, idx) => {
|
||||
const isSelected = expandedWorkflowId === workflow.workflowId
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workflow.workflowId}
|
||||
className={`flex cursor-pointer items-center gap-4 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-[460] text-sm dark:font-medium'>
|
||||
{workflow.workflowName}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-1'>
|
||||
<StatusBar
|
||||
segments={workflow.segments}
|
||||
selectedSegmentIndices={selectedSegments[workflow.workflowId] || null}
|
||||
onSegmentClick={onSegmentClick as any}
|
||||
workflowId={workflow.workflowId}
|
||||
segmentDurationMs={segmentDurationMs}
|
||||
preferBelow={idx < 2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='w-16 flex-shrink-0 text-right'>
|
||||
<span className='font-[460] text-muted-foreground text-sm'>
|
||||
{workflow.overallSuccessRate.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(WorkflowsList)
|
||||
@@ -1,20 +0,0 @@
|
||||
export default function FilterSection({
|
||||
title,
|
||||
content,
|
||||
}: {
|
||||
title: string
|
||||
content?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='font-medium text-muted-foreground text-xs'>{title}</div>
|
||||
<div>
|
||||
{content || (
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Filter options for {title} will go here
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
commandListClass,
|
||||
dropdownContentClass,
|
||||
filterButtonClass,
|
||||
folderDropdownListStyle,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
|
||||
import { useFolders } from '@/hooks/queries/folders'
|
||||
import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
|
||||
const logger = createLogger('LogsFolderFilter')
|
||||
|
||||
interface FolderOption {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
path: string // For nested folders, show full path
|
||||
}
|
||||
|
||||
export default function FolderFilter() {
|
||||
const { folderIds, toggleFolderId, setFolderIds } = useFilterStore()
|
||||
const { getFolderTree } = useFolderStore()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [search, setSearch] = useState('')
|
||||
const { isLoading: foldersLoading } = useFolders(workspaceId)
|
||||
|
||||
const folderTree = workspaceId ? getFolderTree(workspaceId) : []
|
||||
|
||||
const folders: FolderOption[] = useMemo(() => {
|
||||
const flattenFolders = (nodes: FolderTreeNode[], parentPath = ''): FolderOption[] => {
|
||||
const result: FolderOption[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
const currentPath = parentPath ? `${parentPath} / ${node.name}` : node.name
|
||||
result.push({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
color: node.color || '#6B7280',
|
||||
path: currentPath,
|
||||
})
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
result.push(...flattenFolders(node.children, currentPath))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return flattenFolders(folderTree)
|
||||
}, [folderTree])
|
||||
|
||||
// Get display text for the dropdown button
|
||||
const getSelectedFoldersText = () => {
|
||||
if (folderIds.length === 0) return 'All folders'
|
||||
if (folderIds.length === 1) {
|
||||
const selected = folders.find((f) => f.id === folderIds[0])
|
||||
return selected ? selected.name : 'All folders'
|
||||
}
|
||||
return `${folderIds.length} folders selected`
|
||||
}
|
||||
|
||||
// Check if a folder is selected
|
||||
const isFolderSelected = (folderId: string) => {
|
||||
return folderIds.includes(folderId)
|
||||
}
|
||||
|
||||
// Clear all selections
|
||||
const clearSelections = () => {
|
||||
setFolderIds([])
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' className={filterButtonClass}>
|
||||
{foldersLoading ? 'Loading folders...' : getSelectedFoldersText()}
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
avoidCollisions={false}
|
||||
sideOffset={4}
|
||||
className={dropdownContentClass}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search folders...' onValueChange={(v) => setSearch(v)} />
|
||||
<CommandList className={commandListClass} style={folderDropdownListStyle}>
|
||||
<CommandEmpty>
|
||||
{foldersLoading ? 'Loading folders...' : 'No folders found.'}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value='all-folders'
|
||||
onSelect={() => {
|
||||
clearSelections()
|
||||
}}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<span>All folders</span>
|
||||
{folderIds.length === 0 && (
|
||||
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
|
||||
)}
|
||||
</CommandItem>
|
||||
{useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
const filtered = q
|
||||
? folders.filter((f) => (f.path || f.name).toLowerCase().includes(q))
|
||||
: folders
|
||||
return filtered.map((folder) => (
|
||||
<CommandItem
|
||||
key={folder.id}
|
||||
value={`${folder.path || folder.name}`}
|
||||
onSelect={() => {
|
||||
toggleFolderId(folder.id)
|
||||
}}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<span className='truncate' title={folder.path}>
|
||||
{folder.path}
|
||||
</span>
|
||||
</div>
|
||||
{isFolderSelected(folder.id) && (
|
||||
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))
|
||||
}, [folders, search, folderIds])}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export { default as FilterSection } from './filter-section'
|
||||
export { default as FolderFilter } from './folder'
|
||||
export { default as Level } from './level'
|
||||
export { default as Timeline } from './timeline'
|
||||
export { default as Trigger } from './trigger'
|
||||
export { default as Workflow } from './workflow'
|
||||
@@ -1,74 +0,0 @@
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
import type { LogLevel } from '@/stores/logs/filters/types'
|
||||
|
||||
export default function Level() {
|
||||
const { level, setLevel } = useFilterStore()
|
||||
const specificLevels: { value: LogLevel; label: string; color: string }[] = [
|
||||
{ value: 'error', label: 'Error', color: 'bg-destructive/100' },
|
||||
{ value: 'info', label: 'Info', color: 'bg-muted-foreground/100' },
|
||||
]
|
||||
|
||||
const getDisplayLabel = () => {
|
||||
if (level === 'all') return 'Any status'
|
||||
const selected = specificLevels.find((l) => l.value === level)
|
||||
return selected ? selected.label : 'Any status'
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-8 w-full justify-between border-[#E5E5E5] bg-[var(--white)] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
>
|
||||
{getDisplayLabel()}
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
className='w-[180px] border-[#E5E5E5] bg-[var(--white)] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
>
|
||||
<DropdownMenuItem
|
||||
key='all'
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
setLevel('all')
|
||||
}}
|
||||
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<span>Any status</span>
|
||||
{level === 'all' && <Check className='h-4 w-4 text-muted-foreground' />}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{specificLevels.map((levelItem) => (
|
||||
<DropdownMenuItem
|
||||
key={levelItem.value}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
setLevel(levelItem.value)
|
||||
}}
|
||||
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<div className={`mr-2 h-2 w-2 rounded-full ${levelItem.color}`} />
|
||||
{levelItem.label}
|
||||
</div>
|
||||
{level === levelItem.value && <Check className='h-4 w-4 text-muted-foreground' />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
export const filterButtonClass =
|
||||
'w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[var(--white)] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
|
||||
export const dropdownContentClass =
|
||||
'w-[200px] rounded-lg border-[#E5E5E5] bg-[var(--white)] p-0 shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
|
||||
export const commandListClass = 'overflow-y-auto overflow-x-hidden'
|
||||
|
||||
export const workflowDropdownListStyle = {
|
||||
maxHeight: '14rem',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
} as const
|
||||
|
||||
export const folderDropdownListStyle = {
|
||||
maxHeight: '10rem',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
} as const
|
||||
|
||||
export const triggerDropdownListStyle = {
|
||||
maxHeight: '7.5rem',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
} as const
|
||||
|
||||
export const timelineDropdownListStyle = {
|
||||
maxHeight: '9rem',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
} as const
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverScrollArea,
|
||||
PopoverTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { filterButtonClass } from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
import type { TimeRange } from '@/stores/logs/filters/types'
|
||||
|
||||
type TimelineProps = {
|
||||
variant?: 'default' | 'header'
|
||||
}
|
||||
|
||||
export default function Timeline({ variant = 'default' }: TimelineProps = {}) {
|
||||
const { timeRange, setTimeRange } = useFilterStore()
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
|
||||
|
||||
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',
|
||||
]
|
||||
|
||||
const handleTimeRangeSelect = (range: TimeRange) => {
|
||||
setTimeRange(range)
|
||||
setIsPopoverOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='outline' className={filterButtonClass}>
|
||||
{timeRange}
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align={variant === 'header' ? 'end' : 'start'}
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
maxHeight={144}
|
||||
>
|
||||
<PopoverScrollArea>
|
||||
<PopoverItem
|
||||
active={timeRange === 'All time'}
|
||||
showCheck
|
||||
onClick={() => handleTimeRangeSelect('All time')}
|
||||
>
|
||||
All time
|
||||
</PopoverItem>
|
||||
|
||||
{/* Separator */}
|
||||
<div className='my-[2px] h-px bg-[var(--surface-11)]' />
|
||||
|
||||
{specificTimeRanges.map((range) => (
|
||||
<PopoverItem
|
||||
key={range}
|
||||
active={timeRange === range}
|
||||
showCheck
|
||||
onClick={() => handleTimeRangeSelect(range)}
|
||||
>
|
||||
{range}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
|
||||
import {
|
||||
commandListClass,
|
||||
dropdownContentClass,
|
||||
filterButtonClass,
|
||||
triggerDropdownListStyle,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
import type { TriggerType } from '@/stores/logs/filters/types'
|
||||
|
||||
export default function Trigger() {
|
||||
const { triggers, toggleTrigger, setTriggers } = useFilterStore()
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const triggerOptions = useMemo(() => getTriggerOptions(), [])
|
||||
|
||||
const getSelectedTriggersText = () => {
|
||||
if (triggers.length === 0) return 'All triggers'
|
||||
if (triggers.length === 1) {
|
||||
const selected = triggerOptions.find((t) => t.value === triggers[0])
|
||||
return selected ? selected.label : 'All triggers'
|
||||
}
|
||||
return `${triggers.length} triggers selected`
|
||||
}
|
||||
|
||||
const isTriggerSelected = (trigger: TriggerType) => {
|
||||
return triggers.includes(trigger)
|
||||
}
|
||||
|
||||
const clearSelections = () => {
|
||||
setTriggers([])
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' className={filterButtonClass}>
|
||||
{getSelectedTriggersText()}
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
avoidCollisions={false}
|
||||
sideOffset={4}
|
||||
className={dropdownContentClass}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search triggers...' onValueChange={(v) => setSearch(v)} />
|
||||
<CommandList className={commandListClass} style={triggerDropdownListStyle}>
|
||||
<CommandEmpty>No triggers found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value='all-triggers'
|
||||
onSelect={() => clearSelections()}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<span>All triggers</span>
|
||||
{triggers.length === 0 && (
|
||||
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
|
||||
)}
|
||||
</CommandItem>
|
||||
{useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
const filtered = q
|
||||
? triggerOptions.filter((t) => t.label.toLowerCase().includes(q))
|
||||
: triggerOptions
|
||||
return filtered.map((triggerItem) => (
|
||||
<CommandItem
|
||||
key={triggerItem.value}
|
||||
value={triggerItem.label}
|
||||
onSelect={() => toggleTrigger(triggerItem.value)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
{triggerItem.color && (
|
||||
<div
|
||||
className='mr-2 h-2 w-2 rounded-full'
|
||||
style={{ backgroundColor: triggerItem.color }}
|
||||
/>
|
||||
)}
|
||||
{triggerItem.label}
|
||||
</div>
|
||||
{isTriggerSelected(triggerItem.value) && (
|
||||
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))
|
||||
}, [search, triggers])}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
commandListClass,
|
||||
dropdownContentClass,
|
||||
filterButtonClass,
|
||||
workflowDropdownListStyle,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
|
||||
const logger = createLogger('LogsWorkflowFilter')
|
||||
|
||||
interface WorkflowOption {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export default function Workflow() {
|
||||
const { workflowIds, toggleWorkflowId, setWorkflowIds, folderIds } = useFilterStore()
|
||||
const params = useParams()
|
||||
const workspaceId = params?.workspaceId as string | undefined
|
||||
const [workflows, setWorkflows] = useState<WorkflowOption[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const fetchWorkflows = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const query = workspaceId ? `?workspaceId=${encodeURIComponent(workspaceId)}` : ''
|
||||
const response = await fetch(`/api/workflows${query}`)
|
||||
if (response.ok) {
|
||||
const { data } = await response.json()
|
||||
const scoped = Array.isArray(data)
|
||||
? folderIds.length > 0
|
||||
? data.filter((w: any) => (w.folderId ? folderIds.includes(w.folderId) : false))
|
||||
: data
|
||||
: []
|
||||
const workflowOptions: WorkflowOption[] = scoped.map((workflow: any) => ({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
color: workflow.color || '#3972F6',
|
||||
}))
|
||||
setWorkflows(workflowOptions)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch workflows', { error })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchWorkflows()
|
||||
}, [workspaceId, folderIds])
|
||||
|
||||
const getSelectedWorkflowsText = () => {
|
||||
if (workflowIds.length === 0) return 'All workflows'
|
||||
if (workflowIds.length === 1) {
|
||||
const selected = workflows.find((w) => w.id === workflowIds[0])
|
||||
return selected ? selected.name : 'All workflows'
|
||||
}
|
||||
return `${workflowIds.length} workflows selected`
|
||||
}
|
||||
|
||||
const isWorkflowSelected = (workflowId: string) => {
|
||||
return workflowIds.includes(workflowId)
|
||||
}
|
||||
|
||||
const clearSelections = () => {
|
||||
setWorkflowIds([])
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' className={filterButtonClass}>
|
||||
{loading ? 'Loading workflows...' : getSelectedWorkflowsText()}
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
avoidCollisions={false}
|
||||
sideOffset={4}
|
||||
className={dropdownContentClass}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search workflows...' onValueChange={(v) => setSearch(v)} />
|
||||
<CommandList className={commandListClass} style={workflowDropdownListStyle}>
|
||||
<CommandEmpty>{loading ? 'Loading workflows...' : 'No workflows found.'}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value='all-workflows'
|
||||
onSelect={() => {
|
||||
clearSelections()
|
||||
}}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<span>All workflows</span>
|
||||
{workflowIds.length === 0 && (
|
||||
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
|
||||
)}
|
||||
</CommandItem>
|
||||
{useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
const filtered = q
|
||||
? workflows.filter((w) => w.name.toLowerCase().includes(q))
|
||||
: workflows
|
||||
return filtered.map((workflow) => (
|
||||
<CommandItem
|
||||
key={workflow.id}
|
||||
value={`${workflow.name}`}
|
||||
onSelect={() => {
|
||||
toggleWorkflowId(workflow.id)
|
||||
}}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<div
|
||||
className='mr-2 h-2 w-2 rounded-full'
|
||||
style={{ backgroundColor: workflow.color }}
|
||||
/>
|
||||
{workflow.name}
|
||||
</div>
|
||||
{isWorkflowSelected(workflow.id) && (
|
||||
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))
|
||||
}, [workflows, search, workflowIds])}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { TimerOff } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { getSubscriptionStatus } from '@/lib/billing/client/utils'
|
||||
import { isProd } from '@/lib/core/config/environment'
|
||||
import {
|
||||
FilterSection,
|
||||
FolderFilter,
|
||||
Level,
|
||||
Timeline,
|
||||
Trigger,
|
||||
Workflow,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components/filters/components'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
/**
|
||||
* Filters component for logs page - includes timeline and other filter options
|
||||
*/
|
||||
export function Filters() {
|
||||
const { data: subscriptionData, isLoading } = useSubscriptionData()
|
||||
const subscription = getSubscriptionStatus(subscriptionData?.data)
|
||||
const isPaid = subscription.isPaid
|
||||
|
||||
const handleUpgradeClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const event = new CustomEvent('open-settings', {
|
||||
detail: { tab: 'subscription' },
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='h-full w-60 overflow-auto border-r p-4'>
|
||||
{/* Show retention policy for free users in production only */}
|
||||
{!isLoading && !isPaid && isProd && (
|
||||
<div className='mb-4 overflow-hidden border border-border'>
|
||||
<div className='flex items-center gap-2 border-b bg-background p-3'>
|
||||
<TimerOff className='h-4 w-4 text-muted-foreground' />
|
||||
<span className='font-medium text-sm'>Log Retention Policy</span>
|
||||
</div>
|
||||
<div className='p-3'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Logs are automatically deleted after 7 days.
|
||||
</p>
|
||||
<div className='mt-2.5'>
|
||||
<Button
|
||||
variant='default'
|
||||
className='h-8 w-full px-3 text-xs'
|
||||
onClick={handleUpgradeClick}
|
||||
>
|
||||
Upgrade Plan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className='mb-4 pl-2 font-medium text-sm'>Filters</h2>
|
||||
|
||||
{/* Level Filter */}
|
||||
<FilterSection title='Level' content={<Level />} />
|
||||
|
||||
{/* Workflow Filter */}
|
||||
<FilterSection title='Workflow' content={<Workflow />} />
|
||||
|
||||
{/* Folder Filter */}
|
||||
<FilterSection title='Folder' content={<FolderFilter />} />
|
||||
|
||||
{/* Trigger Filter */}
|
||||
<FilterSection title='Trigger' content={<Trigger />} />
|
||||
|
||||
{/* Timeline Filter */}
|
||||
<FilterSection title='Timeline' content={<Timeline />} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Maximize2, Minimize2, X } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { FrozenCanvas } from '@/app/workspace/[workspaceId]/logs/components/frozen-canvas/frozen-canvas'
|
||||
|
||||
interface FrozenCanvasModalProps {
|
||||
executionId: string
|
||||
workflowName?: string
|
||||
trigger?: string
|
||||
traceSpans?: any[] // TraceSpans data from log metadata
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function FrozenCanvasModal({
|
||||
executionId,
|
||||
workflowName,
|
||||
trigger,
|
||||
traceSpans,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: FrozenCanvasModalProps) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
setIsFullscreen(!isFullscreen)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'flex flex-col gap-0 p-0',
|
||||
isFullscreen
|
||||
? 'h-[100vh] max-h-[100vh] w-[100vw] max-w-[100vw]'
|
||||
: 'h-[90vh] max-h-[90vh] overflow-hidden sm:max-w-[1100px]'
|
||||
)}
|
||||
hideCloseButton={true}
|
||||
>
|
||||
{/* Header */}
|
||||
<DialogHeader className='flex flex-row items-center justify-between border-b bg-[var(--surface-1)] p-[16px] dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<div>
|
||||
<DialogTitle className='font-semibold text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
Logged Workflow State
|
||||
</DialogTitle>
|
||||
<div className='mt-[4px] flex items-center gap-[8px]'>
|
||||
{workflowName && (
|
||||
<span className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
||||
{workflowName}
|
||||
</span>
|
||||
)}
|
||||
{trigger && (
|
||||
<Badge variant='secondary' className='text-[12px]'>
|
||||
{trigger}
|
||||
</Badge>
|
||||
)}
|
||||
<span className='font-mono text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
||||
{executionId.slice(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Button variant='ghost' onClick={toggleFullscreen} className='h-[32px] w-[32px] p-0'>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<Maximize2 className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
<Button variant='ghost' onClick={onClose} className='h-[32px] w-[32px] p-0'>
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Canvas Container */}
|
||||
<div className='min-h-0 flex-1'>
|
||||
<FrozenCanvas
|
||||
executionId={executionId}
|
||||
traceSpans={traceSpans}
|
||||
height='100%'
|
||||
width='100%'
|
||||
// Ensure preview leaves padding at edges so nodes don't touch header
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer with instructions */}
|
||||
<div className='border-t bg-[var(--surface-1)] px-[24px] py-[12px] dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
|
||||
<div className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
||||
Click on blocks to see their input and output data at execution time. This canvas shows
|
||||
the exact state of the workflow when this execution was captured.
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { Dashboard } from './dashboard'
|
||||
export { LogDetails } from './log-details'
|
||||
export { FileCards } from './log-details/components/file-download'
|
||||
export { FrozenCanvas } from './log-details/components/frozen-canvas'
|
||||
export { TraceSpans } from './log-details/components/trace-spans'
|
||||
export {
|
||||
AutocompleteSearch,
|
||||
Controls,
|
||||
LogsToolbar,
|
||||
NotificationSettings,
|
||||
} from './logs-toolbar'
|
||||
@@ -0,0 +1,264 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ArrowDown, Loader2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { extractWorkspaceIdFromExecutionKey, getViewerUrl } from '@/lib/uploads/utils/file-utils'
|
||||
|
||||
const logger = createLogger('FileCards')
|
||||
|
||||
interface FileData {
|
||||
id?: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
key: string
|
||||
url: string
|
||||
uploadedAt: string
|
||||
expiresAt: string
|
||||
storageProvider?: 's3' | 'blob' | 'local'
|
||||
bucketName?: string
|
||||
}
|
||||
|
||||
interface FileCardsProps {
|
||||
files: FileData[]
|
||||
isExecutionFile?: boolean
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
interface FileCardProps {
|
||||
file: FileData
|
||||
isExecutionFile?: boolean
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats file size to human readable format
|
||||
*/
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual file card component
|
||||
*/
|
||||
function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps) {
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handleDownload = () => {
|
||||
if (isDownloading) return
|
||||
|
||||
setIsDownloading(true)
|
||||
|
||||
try {
|
||||
logger.info(`Initiating download for file: ${file.name}`)
|
||||
|
||||
if (file.key.startsWith('url/')) {
|
||||
if (file.url) {
|
||||
window.open(file.url, '_blank')
|
||||
logger.info(`Opened URL-type file directly: ${file.url}`)
|
||||
return
|
||||
}
|
||||
throw new Error('URL is required for URL-type files')
|
||||
}
|
||||
|
||||
let resolvedWorkspaceId = workspaceId
|
||||
if (!resolvedWorkspaceId && isExecutionFile) {
|
||||
resolvedWorkspaceId = extractWorkspaceIdFromExecutionKey(file.key) || undefined
|
||||
} else if (!resolvedWorkspaceId) {
|
||||
const segments = file.key.split('/')
|
||||
if (segments.length >= 2 && /^[a-f0-9-]{36}$/.test(segments[0])) {
|
||||
resolvedWorkspaceId = segments[0]
|
||||
}
|
||||
}
|
||||
|
||||
if (isExecutionFile) {
|
||||
const serveUrl =
|
||||
file.url || `/api/files/serve/${encodeURIComponent(file.key)}?context=execution`
|
||||
window.open(serveUrl, '_blank')
|
||||
logger.info(`Opened execution file serve URL: ${serveUrl}`)
|
||||
} else {
|
||||
const viewerUrl = resolvedWorkspaceId ? getViewerUrl(file.key, resolvedWorkspaceId) : null
|
||||
|
||||
if (viewerUrl) {
|
||||
router.push(viewerUrl)
|
||||
logger.info(`Navigated to viewer URL: ${viewerUrl}`)
|
||||
} else {
|
||||
logger.warn(
|
||||
`Could not construct viewer URL for file: ${file.name}, falling back to serve URL`
|
||||
)
|
||||
const serveUrl =
|
||||
file.url || `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
|
||||
window.open(serveUrl, '_blank')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to download file ${file.name}:`, error)
|
||||
if (file.url) {
|
||||
window.open(file.url, '_blank')
|
||||
}
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[8px] rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<span className='truncate font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{file.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[11px] text-[var(--text-subtle)]'>{file.type}</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!h-[20px] !px-[6px] !py-0 text-[11px]'
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<Loader2 className='mr-[4px] h-[10px] w-[10px] animate-spin' />
|
||||
) : (
|
||||
<ArrowDown className='mr-[4px] h-[10px] w-[10px]' />
|
||||
)}
|
||||
{isDownloading ? 'Opening...' : 'Download'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Container component for displaying workflow execution files.
|
||||
* Each file is displayed as a separate card with consistent styling.
|
||||
*/
|
||||
export function FileCards({ files, isExecutionFile = false, workspaceId }: FileCardsProps) {
|
||||
if (!files || files.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Files ({files.length})
|
||||
</span>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{files.map((file, index) => (
|
||||
<FileCard
|
||||
key={file.id || `file-${index}`}
|
||||
file={file}
|
||||
isExecutionFile={isExecutionFile}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Single file download button (legacy export for backwards compatibility)
|
||||
*/
|
||||
export function FileDownload({
|
||||
file,
|
||||
isExecutionFile = false,
|
||||
className,
|
||||
workspaceId,
|
||||
}: {
|
||||
file: FileData
|
||||
isExecutionFile?: boolean
|
||||
className?: string
|
||||
workspaceId?: string
|
||||
}) {
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handleDownload = () => {
|
||||
if (isDownloading) return
|
||||
|
||||
setIsDownloading(true)
|
||||
|
||||
try {
|
||||
logger.info(`Initiating download for file: ${file.name}`)
|
||||
|
||||
if (file.key.startsWith('url/')) {
|
||||
if (file.url) {
|
||||
window.open(file.url, '_blank')
|
||||
logger.info(`Opened URL-type file directly: ${file.url}`)
|
||||
return
|
||||
}
|
||||
throw new Error('URL is required for URL-type files')
|
||||
}
|
||||
|
||||
let resolvedWorkspaceId = workspaceId
|
||||
if (!resolvedWorkspaceId && isExecutionFile) {
|
||||
resolvedWorkspaceId = extractWorkspaceIdFromExecutionKey(file.key) || undefined
|
||||
} else if (!resolvedWorkspaceId) {
|
||||
const segments = file.key.split('/')
|
||||
if (segments.length >= 2 && /^[a-f0-9-]{36}$/.test(segments[0])) {
|
||||
resolvedWorkspaceId = segments[0]
|
||||
}
|
||||
}
|
||||
|
||||
if (isExecutionFile) {
|
||||
const serveUrl =
|
||||
file.url || `/api/files/serve/${encodeURIComponent(file.key)}?context=execution`
|
||||
window.open(serveUrl, '_blank')
|
||||
logger.info(`Opened execution file serve URL: ${serveUrl}`)
|
||||
} else {
|
||||
const viewerUrl = resolvedWorkspaceId ? getViewerUrl(file.key, resolvedWorkspaceId) : null
|
||||
|
||||
if (viewerUrl) {
|
||||
router.push(viewerUrl)
|
||||
logger.info(`Navigated to viewer URL: ${viewerUrl}`)
|
||||
} else {
|
||||
logger.warn(
|
||||
`Could not construct viewer URL for file: ${file.name}, falling back to serve URL`
|
||||
)
|
||||
const serveUrl =
|
||||
file.url || `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
|
||||
window.open(serveUrl, '_blank')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to download file ${file.name}:`, error)
|
||||
if (file.url) {
|
||||
window.open(file.url, '_blank')
|
||||
}
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='ghost'
|
||||
className={`h-7 px-2 text-xs ${className}`}
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<Loader2 className='h-3 w-3 animate-spin' />
|
||||
) : (
|
||||
<ArrowDown className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
{isDownloading ? 'Downloading...' : 'Download'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileCards
|
||||
@@ -0,0 +1 @@
|
||||
export { default, FileCards, FileDownload } from './file-download'
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { redactApiKeys } from '@/lib/core/security/redaction'
|
||||
@@ -35,61 +36,59 @@ function ExpandableDataSection({ title, data }: { title: string; data: any }) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className='mb-[8px] flex items-center justify-between'>
|
||||
<h4 className='font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
{title}
|
||||
</h4>
|
||||
<div className='mb-[6px] flex items-center justify-between'>
|
||||
<h4 className='font-medium text-[13px] text-[var(--text-primary)]'>{title}</h4>
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
{isLargeData && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className='p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
|
||||
title='Expand in modal'
|
||||
type='button'
|
||||
>
|
||||
<Maximize2 className='h-[12px] w-[12px]' />
|
||||
<Maximize2 className='h-[14px] w-[14px]' />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
|
||||
type='button'
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className='h-[12px] w-[12px]' />
|
||||
<ChevronUp className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<ChevronDown className='h-[12px] w-[12px]' />
|
||||
<ChevronDown className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-y-auto bg-[var(--surface-5)] p-[12px] font-mono text-[12px] transition-all duration-200',
|
||||
'overflow-y-auto rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px] font-mono text-[12px] transition-all duration-200',
|
||||
isExpanded ? 'max-h-96' : 'max-h-32'
|
||||
)}
|
||||
>
|
||||
<pre className='whitespace-pre-wrap break-words text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
<pre className='whitespace-pre-wrap break-words text-[var(--text-primary)]'>
|
||||
{jsonString}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal for large data */}
|
||||
{isModalOpen && (
|
||||
<div className='fixed inset-0 z-[200] flex items-center justify-center bg-black/50'>
|
||||
<div className='mx-[16px] h-[80vh] w-full max-w-4xl border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
|
||||
<div className='flex items-center justify-between border-b p-[16px] dark:border-[var(--border)]'>
|
||||
<h3 className='font-medium text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
{title}
|
||||
</h3>
|
||||
<div className='mx-[16px] flex h-[80vh] w-full max-w-4xl flex-col overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] shadow-lg'>
|
||||
<div className='flex items-center justify-between border-[var(--border)] border-b p-[16px]'>
|
||||
<h3 className='font-medium text-[15px] text-[var(--text-primary)]'>{title}</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className='p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
|
||||
type='button'
|
||||
>
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
<X className='h-[16px] w-[16px]' />
|
||||
</button>
|
||||
</div>
|
||||
<div className='h-[calc(80vh-4rem)] overflow-auto p-[16px]'>
|
||||
<pre className='whitespace-pre-wrap break-words font-mono text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
<div className='flex-1 overflow-auto p-[16px]'>
|
||||
<pre className='whitespace-pre-wrap break-words font-mono text-[13px] text-[var(--text-primary)]'>
|
||||
{jsonString}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -170,56 +169,45 @@ function PinnedLogs({
|
||||
workflowState: any
|
||||
onClose: () => void
|
||||
}) {
|
||||
// ALL HOOKS MUST BE CALLED BEFORE ANY CONDITIONAL RETURNS
|
||||
const [currentIterationIndex, setCurrentIterationIndex] = useState(0)
|
||||
|
||||
// Reset iteration index when execution data changes
|
||||
useEffect(() => {
|
||||
setCurrentIterationIndex(0)
|
||||
}, [executionData])
|
||||
|
||||
// Handle case where block has no execution data (e.g., failed workflow)
|
||||
if (!executionData) {
|
||||
const blockInfo = workflowState?.blocks?.[blockId]
|
||||
const formatted = {
|
||||
blockName: blockInfo?.name || 'Unknown Block',
|
||||
blockType: blockInfo?.type || 'unknown',
|
||||
status: 'not_executed',
|
||||
duration: 'N/A',
|
||||
input: null,
|
||||
output: null,
|
||||
errorMessage: null,
|
||||
errorStackTrace: null,
|
||||
cost: null,
|
||||
tokens: null,
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
|
||||
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] shadow-lg'>
|
||||
<CardHeader className='pb-[12px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)]'>
|
||||
<Zap className='h-[16px] w-[16px]' />
|
||||
{formatted.blockName}
|
||||
</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)]'
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
|
||||
type='button'
|
||||
>
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
<X className='h-[16px] w-[16px]' />
|
||||
</button>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Badge variant='secondary'>{formatted.blockType}</Badge>
|
||||
<Badge variant='outline'>not executed</Badge>
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Badge variant='secondary'>{formatted.blockType}</Badge>
|
||||
<Badge variant='outline'>not executed</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='space-y-[16px]'>
|
||||
<div className='bg-[var(--surface-5)] p-[16px] text-center'>
|
||||
<div className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
||||
<div className='rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[16px] text-center'>
|
||||
<div className='text-[13px] text-[var(--text-secondary)]'>
|
||||
This block was not executed because the workflow failed before reaching it.
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,7 +216,6 @@ function PinnedLogs({
|
||||
)
|
||||
}
|
||||
|
||||
// Now we can safely use the execution data
|
||||
const iterationInfo = getCurrentIterationData({
|
||||
...executionData,
|
||||
currentIteration: currentIterationIndex,
|
||||
@@ -250,18 +237,19 @@ function PinnedLogs({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[14px] border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
|
||||
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] shadow-lg'>
|
||||
<CardHeader className='pb-[12px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)]'>
|
||||
<Zap className='h-[16px] w-[16px]' />
|
||||
{formatted.blockName}
|
||||
</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)]'
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
|
||||
type='button'
|
||||
>
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
<X className='h-[16px] w-[16px]' />
|
||||
</button>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
@@ -272,17 +260,17 @@ function PinnedLogs({
|
||||
<Badge variant='outline'>{formatted.status}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Iteration Navigation */}
|
||||
{iterationInfo.hasMultipleIterations && (
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<button
|
||||
onClick={goToPreviousIteration}
|
||||
disabled={currentIterationIndex === 0}
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50 dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
type='button'
|
||||
>
|
||||
<ChevronLeft className='h-[14px] w-[14px]' />
|
||||
</button>
|
||||
<span className='px-[8px] text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
||||
<span className='px-[8px] text-[12px] text-[var(--text-tertiary)]'>
|
||||
{iterationInfo.totalIterations !== undefined
|
||||
? `${currentIterationIndex + 1} / ${iterationInfo.totalIterations}`
|
||||
: `${currentIterationIndex + 1}`}
|
||||
@@ -290,7 +278,8 @@ function PinnedLogs({
|
||||
<button
|
||||
onClick={goToNextIteration}
|
||||
disabled={currentIterationIndex === totalIterations - 1}
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50 dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
type='button'
|
||||
>
|
||||
<ChevronRight className='h-[14px] w-[14px]' />
|
||||
</button>
|
||||
@@ -300,18 +289,16 @@ function PinnedLogs({
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='space-y-[16px]'>
|
||||
<div className='grid grid-cols-2 gap-[16px]'>
|
||||
<div className='grid grid-cols-2 gap-[12px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Clock className='h-[14px] w-[14px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]' />
|
||||
<span className='text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
{formatted.duration}
|
||||
</span>
|
||||
<Clock className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
|
||||
<span className='text-[13px] text-[var(--text-primary)]'>{formatted.duration}</span>
|
||||
</div>
|
||||
|
||||
{formatted.cost && formatted.cost.total > 0 && (
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<DollarSign className='h-[14px] w-[14px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]' />
|
||||
<span className='text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
<DollarSign className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
|
||||
<span className='text-[13px] text-[var(--text-primary)]'>
|
||||
${formatted.cost.total.toFixed(5)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -319,8 +306,8 @@ function PinnedLogs({
|
||||
|
||||
{formatted.tokens && formatted.tokens.total > 0 && (
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Hash className='h-[14px] w-[14px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]' />
|
||||
<span className='text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
<Hash className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
|
||||
<span className='text-[13px] text-[var(--text-primary)]'>
|
||||
{formatted.tokens.total} tokens
|
||||
</span>
|
||||
</div>
|
||||
@@ -333,19 +320,19 @@ function PinnedLogs({
|
||||
|
||||
{formatted.cost && formatted.cost.total > 0 && (
|
||||
<div>
|
||||
<h4 className='mb-[8px] font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
<h4 className='mb-[6px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Cost Breakdown
|
||||
</h4>
|
||||
<div className='space-y-[4px] text-[13px]'>
|
||||
<div className='flex justify-between text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
<div className='space-y-[4px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px] text-[13px]'>
|
||||
<div className='flex justify-between text-[var(--text-primary)]'>
|
||||
<span>Input:</span>
|
||||
<span>${formatted.cost.input.toFixed(5)}</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
<div className='flex justify-between text-[var(--text-primary)]'>
|
||||
<span>Output:</span>
|
||||
<span>${formatted.cost.output.toFixed(5)}</span>
|
||||
</div>
|
||||
<div className='flex justify-between border-t pt-[4px] font-medium text-[var(--text-primary)] dark:border-[var(--border)] dark:text-[var(--text-primary)]'>
|
||||
<div className='flex justify-between border-[var(--border)] border-t pt-[4px] font-medium text-[var(--text-primary)]'>
|
||||
<span>Total:</span>
|
||||
<span>${formatted.cost.total.toFixed(5)}</span>
|
||||
</div>
|
||||
@@ -355,19 +342,19 @@ function PinnedLogs({
|
||||
|
||||
{formatted.tokens && formatted.tokens.total > 0 && (
|
||||
<div>
|
||||
<h4 className='mb-[8px] font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
<h4 className='mb-[6px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Token Usage
|
||||
</h4>
|
||||
<div className='space-y-[4px] text-[13px]'>
|
||||
<div className='flex justify-between text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
<div className='space-y-[4px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px] text-[13px]'>
|
||||
<div className='flex justify-between text-[var(--text-primary)]'>
|
||||
<span>Prompt:</span>
|
||||
<span>{formatted.tokens.prompt}</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
<div className='flex justify-between text-[var(--text-primary)]'>
|
||||
<span>Completion:</span>
|
||||
<span>{formatted.tokens.completion}</span>
|
||||
</div>
|
||||
<div className='flex justify-between border-t pt-[4px] font-medium text-[var(--text-primary)] dark:border-[var(--border)] dark:text-[var(--text-primary)]'>
|
||||
<div className='flex justify-between border-[var(--border)] border-t pt-[4px] font-medium text-[var(--text-primary)]'>
|
||||
<span>Total:</span>
|
||||
<span>{formatted.tokens.total}</span>
|
||||
</div>
|
||||
@@ -404,6 +391,9 @@ interface FrozenCanvasProps {
|
||||
className?: string
|
||||
height?: string | number
|
||||
width?: string | number
|
||||
isModal?: boolean
|
||||
isOpen?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export function FrozenCanvas({
|
||||
@@ -412,6 +402,9 @@ export function FrozenCanvas({
|
||||
className,
|
||||
height = '100%',
|
||||
width = '100%',
|
||||
isModal = false,
|
||||
isOpen = false,
|
||||
onClose,
|
||||
}: FrozenCanvasProps) {
|
||||
const [data, setData] = useState<FrozenCanvasData | null>(null)
|
||||
const [blockExecutions, setBlockExecutions] = useState<Record<string, any>>({})
|
||||
@@ -551,86 +544,115 @@ export function FrozenCanvas({
|
||||
fetchData()
|
||||
}, [executionId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center', className)} style={{ height, width }}>
|
||||
<div className='flex items-center gap-[8px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
||||
<Loader2 className='h-[16px] w-[16px] animate-spin' />
|
||||
<span className='text-[13px]'>Loading frozen canvas...</span>
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center', className)}
|
||||
style={{ height, width }}
|
||||
>
|
||||
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
|
||||
<Loader2 className='h-[16px] w-[16px] animate-spin' />
|
||||
<span className='text-[13px]'>Loading frozen canvas...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center', className)}
|
||||
style={{ height, width }}
|
||||
>
|
||||
<div className='flex items-center gap-[8px] text-[var(--text-error)]'>
|
||||
<AlertCircle className='h-[16px] w-[16px]' />
|
||||
<span className='text-[13px]'>Failed to load frozen canvas: {error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center', className)}
|
||||
style={{ height, width }}
|
||||
>
|
||||
<div className='text-[13px] text-[var(--text-secondary)]'>No data available</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isMigratedLog = (data.workflowState as any)?._migrated === true
|
||||
if (isMigratedLog) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col items-center justify-center gap-[16px] p-[32px]', className)}
|
||||
style={{ height, width }}
|
||||
>
|
||||
<div className='flex items-center gap-[12px] text-[var(--text-warning)]'>
|
||||
<AlertCircle className='h-[20px] w-[20px]' />
|
||||
<span className='font-medium text-[15px]'>Logged State Not Found</span>
|
||||
</div>
|
||||
<div className='max-w-md text-center text-[13px] text-[var(--text-secondary)]'>
|
||||
This log was migrated from the old logging system. The workflow state at execution time
|
||||
is not available.
|
||||
</div>
|
||||
<div className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
Note: {(data.workflowState as any)?._note}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{ height, width }}
|
||||
className={cn('frozen-canvas-mode h-full w-full', className)}
|
||||
>
|
||||
<WorkflowPreview
|
||||
workflowState={data.workflowState}
|
||||
showSubBlocks={true}
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={0.8}
|
||||
onNodeClick={(blockId) => {
|
||||
setPinnedBlockId(blockId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pinnedBlockId && (
|
||||
<PinnedLogs
|
||||
executionData={blockExecutions[pinnedBlockId] || null}
|
||||
blockId={pinnedBlockId}
|
||||
workflowState={data.workflowState}
|
||||
onClose={() => setPinnedBlockId(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (isModal) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center', className)} style={{ height, width }}>
|
||||
<div className='flex items-center gap-[8px] text-[var(--text-error)] dark:text-[var(--text-error)]'>
|
||||
<AlertCircle className='h-[16px] w-[16px]' />
|
||||
<span className='text-[13px]'>Failed to load frozen canvas: {error}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Modal open={isOpen} onOpenChange={onClose}>
|
||||
<ModalContent size='xl' className='flex h-[90vh] flex-col'>
|
||||
<ModalHeader>Workflow State</ModalHeader>
|
||||
|
||||
<ModalBody className='min-h-0 flex-1'>
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='min-h-0 flex-1 overflow-hidden rounded-[4px] border border-[var(--border)]'>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center', className)} style={{ height, width }}>
|
||||
<div className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
||||
No data available
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if this is a migrated log without real workflow state
|
||||
const isMigratedLog = (data.workflowState as any)?._migrated === true
|
||||
if (isMigratedLog) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col items-center justify-center gap-[16px] p-[32px]', className)}
|
||||
style={{ height, width }}
|
||||
>
|
||||
<div className='flex items-center gap-[12px] text-amber-600 dark:text-amber-400'>
|
||||
<AlertCircle className='h-[20px] w-[20px]' />
|
||||
<span className='font-medium text-[15px]'>Logged State Not Found</span>
|
||||
</div>
|
||||
<div className='max-w-md text-center text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
||||
This log was migrated from the old logging system. The workflow state at execution time is
|
||||
not available.
|
||||
</div>
|
||||
<div className='text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
||||
Note: {(data.workflowState as any)?._note}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ height, width }} className={cn('frozen-canvas-mode h-full w-full', className)}>
|
||||
<WorkflowPreview
|
||||
workflowState={data.workflowState}
|
||||
showSubBlocks={true}
|
||||
isPannable={true}
|
||||
defaultZoom={0.8}
|
||||
fitPadding={0.25}
|
||||
onNodeClick={(blockId) => {
|
||||
// Always allow clicking blocks, even if they don't have execution data
|
||||
// This is important for failed workflows where some blocks never executed
|
||||
setPinnedBlockId(blockId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pinnedBlockId && (
|
||||
<PinnedLogs
|
||||
executionData={blockExecutions[pinnedBlockId] || null}
|
||||
blockId={pinnedBlockId}
|
||||
workflowState={data.workflowState}
|
||||
onClose={() => setPinnedBlockId(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
return renderContent()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { FrozenCanvas } from './frozen-canvas'
|
||||
@@ -0,0 +1 @@
|
||||
export { TraceSpans } from './trace-spans'
|
||||
@@ -0,0 +1,630 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { highlight, languages } from 'prismjs'
|
||||
import 'prismjs/components/prism-json'
|
||||
import clsx from 'clsx'
|
||||
import { Button, ChevronDown } from '@/components/emcn'
|
||||
import type { TraceSpan } from '@/stores/logs/filters/types'
|
||||
import '@/components/emcn/components/code/code.css'
|
||||
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import { getBlock, getBlockByToolName } from '@/blocks'
|
||||
|
||||
interface TraceSpansProps {
|
||||
traceSpans?: TraceSpan[]
|
||||
totalDuration?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique key for a trace span
|
||||
*/
|
||||
function getSpanKey(span: TraceSpan): string {
|
||||
if (span.id) {
|
||||
return span.id
|
||||
}
|
||||
const name = span.name || 'span'
|
||||
const start = span.startTime || 'unknown-start'
|
||||
const end = span.endTime || 'unknown-end'
|
||||
return `${name}|${start}|${end}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges multiple arrays of trace span children, deduplicating by span key
|
||||
*/
|
||||
function mergeTraceSpanChildren(...groups: TraceSpan[][]): TraceSpan[] {
|
||||
const merged: TraceSpan[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
groups.forEach((group) => {
|
||||
group.forEach((child) => {
|
||||
const key = getSpanKey(child)
|
||||
if (seen.has(key)) {
|
||||
return
|
||||
}
|
||||
seen.add(key)
|
||||
merged.push(child)
|
||||
})
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a trace span by merging children from both the children array
|
||||
* and any childTraceSpans in the output
|
||||
*/
|
||||
function normalizeChildWorkflowSpan(span: TraceSpan): TraceSpan {
|
||||
const enrichedSpan: TraceSpan = { ...span }
|
||||
|
||||
if (enrichedSpan.output && typeof enrichedSpan.output === 'object') {
|
||||
enrichedSpan.output = { ...enrichedSpan.output }
|
||||
}
|
||||
|
||||
const normalizedChildren = Array.isArray(span.children)
|
||||
? span.children.map((childSpan) => normalizeChildWorkflowSpan(childSpan))
|
||||
: []
|
||||
|
||||
const outputChildSpans = Array.isArray(span.output?.childTraceSpans)
|
||||
? (span.output!.childTraceSpans as TraceSpan[]).map((childSpan) =>
|
||||
normalizeChildWorkflowSpan(childSpan)
|
||||
)
|
||||
: []
|
||||
|
||||
const mergedChildren = mergeTraceSpanChildren(normalizedChildren, outputChildSpans)
|
||||
|
||||
if (
|
||||
enrichedSpan.output &&
|
||||
typeof enrichedSpan.output === 'object' &&
|
||||
enrichedSpan.output !== null &&
|
||||
'childTraceSpans' in enrichedSpan.output
|
||||
) {
|
||||
const { childTraceSpans, ...cleanOutput } = enrichedSpan.output as {
|
||||
childTraceSpans?: TraceSpan[]
|
||||
} & Record<string, unknown>
|
||||
enrichedSpan.output = cleanOutput
|
||||
}
|
||||
|
||||
enrichedSpan.children = mergedChildren.length > 0 ? mergedChildren : undefined
|
||||
|
||||
return enrichedSpan
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats duration in ms
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets color for block type
|
||||
*/
|
||||
function getBlockColor(type: string): string {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'agent':
|
||||
return 'var(--brand-primary-hover-hex)'
|
||||
case 'model':
|
||||
return 'var(--brand-primary-hover-hex)'
|
||||
case 'function':
|
||||
return '#FF402F'
|
||||
case 'tool':
|
||||
return '#f97316'
|
||||
case 'router':
|
||||
return '#2FA1FF'
|
||||
case 'condition':
|
||||
return '#FF972F'
|
||||
case 'evaluator':
|
||||
return '#2FA1FF'
|
||||
case 'api':
|
||||
return '#2F55FF'
|
||||
default:
|
||||
return '#6b7280'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets icon and color for block type
|
||||
*/
|
||||
function getBlockIconAndColor(type: string): {
|
||||
icon: React.ComponentType<{ className?: string }> | null
|
||||
bgColor: string
|
||||
} {
|
||||
const lowerType = type.toLowerCase()
|
||||
|
||||
if (lowerType === 'loop') {
|
||||
return { icon: LoopTool.icon, bgColor: LoopTool.bgColor }
|
||||
}
|
||||
if (lowerType === 'parallel') {
|
||||
return { icon: ParallelTool.icon, bgColor: ParallelTool.bgColor }
|
||||
}
|
||||
|
||||
const blockType = lowerType === 'model' ? 'agent' : lowerType
|
||||
const blockConfig = getBlock(blockType)
|
||||
if (blockConfig) {
|
||||
return { icon: blockConfig.icon, bgColor: blockConfig.bgColor }
|
||||
}
|
||||
|
||||
return { icon: null, bgColor: getBlockColor(type) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the progress bar showing execution timeline
|
||||
*/
|
||||
function ProgressBar({
|
||||
span,
|
||||
childSpans,
|
||||
workflowStartTime,
|
||||
totalDuration,
|
||||
}: {
|
||||
span: TraceSpan
|
||||
childSpans?: TraceSpan[]
|
||||
workflowStartTime: number
|
||||
totalDuration: number
|
||||
}) {
|
||||
const segments = useMemo(() => {
|
||||
if (!childSpans || childSpans.length === 0) {
|
||||
const startMs = new Date(span.startTime).getTime()
|
||||
const endMs = new Date(span.endTime).getTime()
|
||||
const duration = endMs - startMs
|
||||
const startPercent =
|
||||
totalDuration > 0 ? ((startMs - workflowStartTime) / totalDuration) * 100 : 0
|
||||
const widthPercent = totalDuration > 0 ? (duration / totalDuration) * 100 : 0
|
||||
|
||||
let color = getBlockColor(span.type)
|
||||
if (span.type?.toLowerCase() === 'tool' && span.name) {
|
||||
const toolBlock = getBlockByToolName(span.name)
|
||||
if (toolBlock?.bgColor) {
|
||||
color = toolBlock.bgColor
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
startPercent: Math.max(0, Math.min(100, startPercent)),
|
||||
widthPercent: Math.max(0.5, Math.min(100, widthPercent)),
|
||||
color,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return childSpans.map((child) => {
|
||||
const startMs = new Date(child.startTime).getTime()
|
||||
const endMs = new Date(child.endTime).getTime()
|
||||
const duration = endMs - startMs
|
||||
const startPercent =
|
||||
totalDuration > 0 ? ((startMs - workflowStartTime) / totalDuration) * 100 : 0
|
||||
const widthPercent = totalDuration > 0 ? (duration / totalDuration) * 100 : 0
|
||||
|
||||
let color = getBlockColor(child.type)
|
||||
if (child.type?.toLowerCase() === 'tool' && child.name) {
|
||||
const toolBlock = getBlockByToolName(child.name)
|
||||
if (toolBlock?.bgColor) {
|
||||
color = toolBlock.bgColor
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startPercent: Math.max(0, Math.min(100, startPercent)),
|
||||
widthPercent: Math.max(0.5, Math.min(100, widthPercent)),
|
||||
color,
|
||||
}
|
||||
})
|
||||
}, [span, childSpans, workflowStartTime, totalDuration])
|
||||
|
||||
return (
|
||||
<div className='relative mb-[8px] h-[5px] w-full overflow-hidden rounded-[18px] bg-[var(--divider)]'>
|
||||
{segments.map((segment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='absolute h-full'
|
||||
style={{
|
||||
left: `${segment.startPercent}%`,
|
||||
width: `${segment.widthPercent}%`,
|
||||
backgroundColor: segment.color,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders input/output section with collapsible content
|
||||
*/
|
||||
function InputOutputSection({
|
||||
label,
|
||||
data,
|
||||
isError,
|
||||
spanId,
|
||||
sectionType,
|
||||
expandedSections,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string
|
||||
data: unknown
|
||||
isError: boolean
|
||||
spanId: string
|
||||
sectionType: 'input' | 'output'
|
||||
expandedSections: Set<string>
|
||||
onToggle: (section: string) => void
|
||||
}) {
|
||||
const sectionKey = `${spanId}-${sectionType}`
|
||||
const isExpanded = expandedSections.has(sectionKey)
|
||||
|
||||
const jsonString = useMemo(() => {
|
||||
if (!data) return ''
|
||||
return JSON.stringify(data, null, 2)
|
||||
}, [data])
|
||||
|
||||
const highlightedCode = useMemo(() => {
|
||||
if (!jsonString) return ''
|
||||
return highlight(jsonString, languages.json, 'json')
|
||||
}, [jsonString])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span
|
||||
className='font-medium text-[12px]'
|
||||
style={{ color: isError ? 'var(--text-error)' : 'var(--text-tertiary)' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!h-[18px] !w-[18px] !p-0'
|
||||
onClick={() => onToggle(sectionKey)}
|
||||
>
|
||||
<ChevronDown
|
||||
className='h-[10px] w-[10px] text-[var(--text-subtle)] transition-transform'
|
||||
style={{
|
||||
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{isError && typeof data === 'object' && data !== null && 'error' in data ? (
|
||||
<div
|
||||
className='rounded-[6px] px-[10px] py-[8px]'
|
||||
style={{
|
||||
backgroundColor: 'var(--terminal-status-error-bg)',
|
||||
color: 'var(--text-error)',
|
||||
}}
|
||||
>
|
||||
<div className='font-medium text-[12px]'>Error</div>
|
||||
<div className='mt-[4px] text-[12px]'>{(data as { error: string }).error}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='code-editor-theme overflow-hidden rounded-[6px] bg-[var(--surface-3)] px-[10px] py-[8px]'>
|
||||
<pre
|
||||
className='m-0 w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
|
||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TraceSpanItemProps {
|
||||
span: TraceSpan
|
||||
totalDuration: number
|
||||
workflowStartTime: number
|
||||
onToggle: (spanId: string, expanded: boolean) => void
|
||||
expandedSpans: Set<string>
|
||||
isFirstSpan?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual trace span card component
|
||||
*/
|
||||
function TraceSpanItem({
|
||||
span,
|
||||
totalDuration,
|
||||
workflowStartTime,
|
||||
onToggle,
|
||||
expandedSpans,
|
||||
isFirstSpan = false,
|
||||
}: TraceSpanItemProps): React.ReactNode {
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
|
||||
|
||||
const spanId = span.id || `span-${span.name}-${span.startTime}`
|
||||
const spanStartTime = new Date(span.startTime).getTime()
|
||||
const spanEndTime = new Date(span.endTime).getTime()
|
||||
const duration = span.duration || spanEndTime - spanStartTime
|
||||
|
||||
const hasChildren = span.children && span.children.length > 0
|
||||
const hasToolCalls = span.toolCalls && span.toolCalls.length > 0
|
||||
const hasInput = Boolean(span.input)
|
||||
const hasOutput = Boolean(span.output)
|
||||
const isError = span.status === 'error'
|
||||
|
||||
const inlineChildTypes = new Set(['tool', 'model'])
|
||||
const inlineChildren =
|
||||
span.children?.filter((child) => inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
|
||||
const otherChildren =
|
||||
span.children?.filter((child) => !inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
|
||||
|
||||
const toolCallSpans = useMemo(() => {
|
||||
if (!hasToolCalls) return []
|
||||
return span.toolCalls!.map((toolCall, index) => {
|
||||
const toolStartTime = toolCall.startTime
|
||||
? new Date(toolCall.startTime).getTime()
|
||||
: spanStartTime
|
||||
const toolEndTime = toolCall.endTime
|
||||
? new Date(toolCall.endTime).getTime()
|
||||
: toolStartTime + (toolCall.duration || 0)
|
||||
|
||||
return {
|
||||
id: `${spanId}-tool-${index}`,
|
||||
name: toolCall.name,
|
||||
type: 'tool',
|
||||
duration: toolCall.duration || toolEndTime - toolStartTime,
|
||||
startTime: new Date(toolStartTime).toISOString(),
|
||||
endTime: new Date(toolEndTime).toISOString(),
|
||||
status: toolCall.error ? ('error' as const) : ('success' as const),
|
||||
input: toolCall.input,
|
||||
output: toolCall.error
|
||||
? { error: toolCall.error, ...(toolCall.output || {}) }
|
||||
: toolCall.output,
|
||||
} as TraceSpan
|
||||
})
|
||||
}, [hasToolCalls, span.toolCalls, spanId, spanStartTime])
|
||||
|
||||
const handleSectionToggle = (section: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(section)) {
|
||||
next.delete(section)
|
||||
} else {
|
||||
next.add(section)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col gap-[8px] rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{!isFirstSpan && (
|
||||
<div
|
||||
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{BlockIcon && <BlockIcon className={clsx('text-white', '!h-[9px] !w-[9px]')} />}
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className='font-medium text-[12px]'
|
||||
style={{ color: isError ? 'var(--text-error)' : 'var(--text-secondary)' }}
|
||||
>
|
||||
{span.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
{formatDuration(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
span={span}
|
||||
childSpans={span.children}
|
||||
workflowStartTime={workflowStartTime}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{hasInput && (
|
||||
<InputOutputSection
|
||||
label='Input'
|
||||
data={span.input}
|
||||
isError={false}
|
||||
spanId={spanId}
|
||||
sectionType='input'
|
||||
expandedSections={expandedSections}
|
||||
onToggle={handleSectionToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasInput && hasOutput && <div className='border-[var(--border)] border-t border-dashed' />}
|
||||
|
||||
{hasOutput && (
|
||||
<InputOutputSection
|
||||
label={isError ? 'Error' : 'Output'}
|
||||
data={span.output}
|
||||
isError={isError}
|
||||
spanId={spanId}
|
||||
sectionType='output'
|
||||
expandedSections={expandedSections}
|
||||
onToggle={handleSectionToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(hasToolCalls || inlineChildren.length > 0) &&
|
||||
[...toolCallSpans, ...inlineChildren].map((childSpan, index) => {
|
||||
const childId = childSpan.id || `${spanId}-inline-${index}`
|
||||
const childIsError = childSpan.status === 'error'
|
||||
const isInitialResponse = (childSpan.name || '')
|
||||
.toLowerCase()
|
||||
.includes('initial response')
|
||||
|
||||
const shouldRenderSeparator =
|
||||
index === 0 && (hasInput || hasOutput) && !isInitialResponse
|
||||
|
||||
const toolBlock =
|
||||
childSpan.type?.toLowerCase() === 'tool' && childSpan.name
|
||||
? getBlockByToolName(childSpan.name)
|
||||
: null
|
||||
const { icon: ChildIcon, bgColor: childBgColor } = toolBlock
|
||||
? { icon: toolBlock.icon, bgColor: toolBlock.bgColor }
|
||||
: getBlockIconAndColor(childSpan.type)
|
||||
|
||||
return (
|
||||
<div key={`inline-${childId}`}>
|
||||
{shouldRenderSeparator && (
|
||||
<div className='border-[var(--border)] border-t border-dashed' />
|
||||
)}
|
||||
|
||||
<div className='mt-[8px] flex flex-col gap-[8px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div
|
||||
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
|
||||
style={{ background: childBgColor }}
|
||||
>
|
||||
{ChildIcon && (
|
||||
<ChildIcon className={clsx('text-white', '!h-[9px] !w-[9px]')} />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className='font-medium text-[12px]'
|
||||
style={{
|
||||
color: childIsError ? 'var(--text-error)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{childSpan.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
{formatDuration(childSpan.duration || 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
span={childSpan}
|
||||
childSpans={undefined}
|
||||
workflowStartTime={workflowStartTime}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{childSpan.input && (
|
||||
<InputOutputSection
|
||||
label='Input'
|
||||
data={childSpan.input}
|
||||
isError={false}
|
||||
spanId={`${childId}-input`}
|
||||
sectionType='input'
|
||||
expandedSections={expandedSections}
|
||||
onToggle={handleSectionToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{childSpan.input && childSpan.output && (
|
||||
<div className='border-[var(--border)] border-t border-dashed' />
|
||||
)}
|
||||
|
||||
{childSpan.output && (
|
||||
<InputOutputSection
|
||||
label={childIsError ? 'Error' : 'Output'}
|
||||
data={childSpan.output}
|
||||
isError={childIsError}
|
||||
spanId={`${childId}-output`}
|
||||
sectionType='output'
|
||||
expandedSections={expandedSections}
|
||||
onToggle={handleSectionToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{otherChildren.map((childSpan, index) => {
|
||||
const enrichedChildSpan = normalizeChildWorkflowSpan(childSpan)
|
||||
return (
|
||||
<TraceSpanItem
|
||||
key={index}
|
||||
span={enrichedChildSpan}
|
||||
totalDuration={totalDuration}
|
||||
workflowStartTime={workflowStartTime}
|
||||
onToggle={onToggle}
|
||||
expandedSpans={expandedSpans}
|
||||
isFirstSpan={false}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays workflow execution trace spans with nested structure
|
||||
*/
|
||||
export function TraceSpans({ traceSpans, totalDuration = 0 }: TraceSpansProps) {
|
||||
const [expandedSpans, setExpandedSpans] = useState<Set<string>>(new Set())
|
||||
|
||||
const workflowStartTime = useMemo(() => {
|
||||
if (!traceSpans || traceSpans.length === 0) return 0
|
||||
return traceSpans.reduce((earliest, span) => {
|
||||
const startTime = new Date(span.startTime).getTime()
|
||||
return startTime < earliest ? startTime : earliest
|
||||
}, Number.POSITIVE_INFINITY)
|
||||
}, [traceSpans])
|
||||
|
||||
const workflowEndTime = useMemo(() => {
|
||||
if (!traceSpans || traceSpans.length === 0) return 0
|
||||
return traceSpans.reduce((latest, span) => {
|
||||
const endTime = span.endTime ? new Date(span.endTime).getTime() : 0
|
||||
return endTime > latest ? endTime : latest
|
||||
}, 0)
|
||||
}, [traceSpans])
|
||||
|
||||
const actualTotalDuration = workflowEndTime - workflowStartTime
|
||||
|
||||
const handleSpanToggle = useCallback((spanId: string, expanded: boolean) => {
|
||||
setExpandedSpans((prev) => {
|
||||
const newExpandedSpans = new Set(prev)
|
||||
if (expanded) {
|
||||
newExpandedSpans.add(spanId)
|
||||
} else {
|
||||
newExpandedSpans.delete(spanId)
|
||||
}
|
||||
return newExpandedSpans
|
||||
})
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const filterTree = (spans: TraceSpan[]): TraceSpan[] =>
|
||||
spans
|
||||
.map((s) => normalizeChildWorkflowSpan(s))
|
||||
.map((s) => ({
|
||||
...s,
|
||||
children: s.children ? filterTree(s.children) : undefined,
|
||||
}))
|
||||
return traceSpans ? filterTree(traceSpans) : []
|
||||
}, [traceSpans])
|
||||
|
||||
if (!traceSpans || traceSpans.length === 0) {
|
||||
return <div className='text-[12px] text-[var(--text-secondary)]'>No trace data available</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>Trace Span</span>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{filtered.map((span, index) => (
|
||||
<TraceSpanItem
|
||||
key={index}
|
||||
span={span}
|
||||
totalDuration={actualTotalDuration !== undefined ? actualTotalDuration : totalDuration}
|
||||
workflowStartTime={workflowStartTime}
|
||||
onToggle={handleSpanToggle}
|
||||
expandedSpans={expandedSpans}
|
||||
isFirstSpan={index === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { LogDetails } from './log-details'
|
||||
@@ -0,0 +1,336 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronUp, X } from 'lucide-react'
|
||||
import { Button, Eye } from '@/components/emcn'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
||||
import { FileCards, FrozenCanvas, TraceSpans } from '@/app/workspace/[workspaceId]/logs/components'
|
||||
import type { LogStatus } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { formatDate, StatusBadge, TriggerBadge } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { formatCost } from '@/providers/utils'
|
||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
|
||||
interface LogDetailsProps {
|
||||
/** The log to display details for */
|
||||
log: WorkflowLog | null
|
||||
/** Whether the sidebar is open */
|
||||
isOpen: boolean
|
||||
/** Callback when closing the sidebar */
|
||||
onClose: () => void
|
||||
/** Callback to navigate to next log */
|
||||
onNavigateNext?: () => void
|
||||
/** Callback to navigate to previous log */
|
||||
onNavigatePrev?: () => void
|
||||
/** Whether there is a next log available */
|
||||
hasNext?: boolean
|
||||
/** Whether there is a previous log available */
|
||||
hasPrev?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar panel displaying detailed information about a selected log.
|
||||
* Supports navigation between logs and expandable sections.
|
||||
* @param props - Component props
|
||||
* @returns Log details sidebar component
|
||||
*/
|
||||
export function LogDetails({
|
||||
log,
|
||||
isOpen,
|
||||
onClose,
|
||||
onNavigateNext,
|
||||
onNavigatePrev,
|
||||
hasNext = false,
|
||||
hasPrev = false,
|
||||
}: LogDetailsProps) {
|
||||
const [isFrozenCanvasOpen, setIsFrozenCanvasOpen] = useState(false)
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollAreaRef.current) {
|
||||
scrollAreaRef.current.scrollTop = 0
|
||||
}
|
||||
}, [log?.id])
|
||||
|
||||
const isWorkflowExecutionLog = useMemo(() => {
|
||||
if (!log) return false
|
||||
return (
|
||||
(log.trigger === 'manual' && !!log.duration) ||
|
||||
(log.executionData?.enhanced && log.executionData?.traceSpans)
|
||||
)
|
||||
}, [log])
|
||||
|
||||
const hasCostInfo = useMemo(() => {
|
||||
return isWorkflowExecutionLog && log?.cost
|
||||
}, [log, isWorkflowExecutionLog])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
if (e.key === 'ArrowUp' && hasPrev && onNavigatePrev) {
|
||||
e.preventDefault()
|
||||
handleNavigate(onNavigatePrev)
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown' && hasNext && onNavigateNext) {
|
||||
e.preventDefault()
|
||||
handleNavigate(onNavigateNext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, onClose, hasPrev, hasNext, onNavigatePrev, onNavigateNext])
|
||||
|
||||
const handleNavigate = (navigateFunction: () => void) => {
|
||||
navigateFunction()
|
||||
}
|
||||
|
||||
const formattedTimestamp = log ? formatDate(log.createdAt) : null
|
||||
|
||||
const logStatus: LogStatus = useMemo(() => {
|
||||
if (!log) return 'info'
|
||||
const baseLevel = (log.level || 'info').toLowerCase()
|
||||
const isError = baseLevel === 'error'
|
||||
const isPending = !isError && log.hasPendingPause === true
|
||||
const isRunning = !isError && !isPending && log.duration === null
|
||||
return isError ? 'error' : isPending ? 'pending' : isRunning ? 'running' : 'info'
|
||||
}, [log])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute top-[0px] right-0 bottom-0 z-50 w-[384px] transform overflow-hidden border-l bg-[var(--surface-1)] shadow-lg transition-transform duration-200 ease-out ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
aria-label='Log details sidebar'
|
||||
>
|
||||
{log && (
|
||||
<div className='flex h-full flex-col px-[14px] pt-[12px]'>
|
||||
{/* Header */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium text-[14px] text-[var(--text-primary)]'>Log Details</h2>
|
||||
<div className='flex items-center gap-[1px]'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-[4px]'
|
||||
onClick={() => hasPrev && handleNavigate(onNavigatePrev!)}
|
||||
disabled={!hasPrev}
|
||||
aria-label='Previous log'
|
||||
>
|
||||
<ChevronUp className='h-[14px] w-[14px] rotate-180' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-[4px]'
|
||||
onClick={() => hasNext && handleNavigate(onNavigateNext!)}
|
||||
disabled={!hasNext}
|
||||
aria-label='Next log'
|
||||
>
|
||||
<ChevronUp className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
<Button variant='ghost' className='!p-[4px]' onClick={onClose} aria-label='Close'>
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<ScrollArea className='mt-[20px] h-full w-full overflow-y-auto' ref={scrollAreaRef}>
|
||||
<div className='flex flex-col gap-[10px] pb-[16px]'>
|
||||
{/* Timestamp & Workflow Row */}
|
||||
<div className='flex items-center gap-[16px] px-[1px]'>
|
||||
{/* Timestamp Card */}
|
||||
<div className='flex w-[140px] flex-col gap-[8px]'>
|
||||
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Timestamp
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
{formattedTimestamp?.compactDate || 'N/A'}
|
||||
</span>
|
||||
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
{formattedTimestamp?.compactTime || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow Card */}
|
||||
{log.workflow && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Workflow
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
|
||||
style={{ backgroundColor: log.workflow?.color }}
|
||||
/>
|
||||
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
{log.workflow.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Execution ID */}
|
||||
{log.executionId && (
|
||||
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Execution ID
|
||||
</span>
|
||||
<span className='truncate font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
{log.executionId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Section */}
|
||||
<div className='flex flex-col'>
|
||||
{/* Level */}
|
||||
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>Level</span>
|
||||
<StatusBadge status={logStatus} />
|
||||
</div>
|
||||
|
||||
{/* Trigger */}
|
||||
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Trigger
|
||||
</span>
|
||||
{log.trigger ? (
|
||||
<TriggerBadge trigger={log.trigger} />
|
||||
) : (
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className='flex h-[48px] items-center justify-between p-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Duration
|
||||
</span>
|
||||
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
{log.duration || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow State */}
|
||||
{isWorkflowExecutionLog && log.executionId && (
|
||||
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Workflow State
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsFrozenCanvasOpen(true)}
|
||||
className='flex items-center justify-between rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px] transition-colors hover:bg-[var(--c-2A2A2A)]'
|
||||
>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
View Snapshot
|
||||
</span>
|
||||
<Eye className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workflow Execution - Trace Spans */}
|
||||
{isWorkflowExecutionLog && log.executionData?.traceSpans && (
|
||||
<TraceSpans
|
||||
traceSpans={log.executionData.traceSpans}
|
||||
totalDuration={log.executionData.totalDuration}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{log.files && log.files.length > 0 && <FileCards files={log.files} isExecutionFile />}
|
||||
|
||||
{/* Cost Breakdown */}
|
||||
{hasCostInfo && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='px-[1px] font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Cost Breakdown
|
||||
</span>
|
||||
|
||||
<div className='flex flex-col gap-[4px] rounded-[6px] border border-[var(--border)]'>
|
||||
<div className='flex flex-col gap-[10px] rounded-[6px] p-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Base Execution:
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{formatCost(BASE_EXECUTION_CHARGE)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Model Input:
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{formatCost(log.cost?.input || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Model Output:
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{formatCost(log.cost?.output || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='border-[var(--border)] border-t' />
|
||||
|
||||
<div className='flex flex-col gap-[10px] rounded-[6px] p-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Total:
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{formatCost(log.cost?.total || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Tokens:
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{log.cost?.tokens?.prompt || 0} in / {log.cost?.tokens?.completion || 0}{' '}
|
||||
out
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-center rounded-[6px] bg-[var(--surface-2)] p-[8px] text-center'>
|
||||
<p className='font-medium text-[11px] text-[var(--text-subtle)]'>
|
||||
Total cost includes a base execution charge of{' '}
|
||||
{formatCost(BASE_EXECUTION_CHARGE)} plus any model usage costs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Frozen Canvas Modal */}
|
||||
{log?.executionId && (
|
||||
<FrozenCanvas
|
||||
executionId={log.executionId}
|
||||
traceSpans={log.executionData?.traceSpans}
|
||||
isModal
|
||||
isOpen={isFrozenCanvasOpen}
|
||||
onClose={() => setIsFrozenCanvasOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ArrowUp, Bell, Loader2, RefreshCw, Search } from 'lucide-react'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { ArrowUp, Bell, ChevronDown, Loader2, RefreshCw, Search } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
@@ -13,7 +13,83 @@ import { MoreHorizontal } from '@/components/emcn/icons'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import Timeline from '@/app/workspace/[workspaceId]/logs/components/filters/components/timeline'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
import type { TimeRange } from '@/stores/logs/filters/types'
|
||||
|
||||
const FILTER_BUTTON_CLASS =
|
||||
'w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[var(--white)] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
|
||||
type TimelineProps = {
|
||||
variant?: 'default' | 'header'
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeline component for time range selection.
|
||||
* Displays a dropdown with predefined time ranges.
|
||||
* @param props - The component props
|
||||
* @returns Time range selector dropdown
|
||||
*/
|
||||
function Timeline({ variant = 'default' }: TimelineProps = {}) {
|
||||
const { timeRange, setTimeRange } = useFilterStore()
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
|
||||
|
||||
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',
|
||||
]
|
||||
|
||||
const handleTimeRangeSelect = (range: TimeRange) => {
|
||||
setTimeRange(range)
|
||||
setIsPopoverOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='outline' className={FILTER_BUTTON_CLASS}>
|
||||
{timeRange}
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align={variant === 'header' ? 'end' : 'start'}
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
maxHeight={144}
|
||||
>
|
||||
<PopoverScrollArea>
|
||||
<PopoverItem
|
||||
active={timeRange === 'All time'}
|
||||
showCheck
|
||||
onClick={() => handleTimeRangeSelect('All time')}
|
||||
>
|
||||
All time
|
||||
</PopoverItem>
|
||||
|
||||
<div className='my-[2px] h-px bg-[var(--surface-11)]' />
|
||||
|
||||
{specificTimeRanges.map((range) => (
|
||||
<PopoverItem
|
||||
key={range}
|
||||
active={timeRange === range}
|
||||
showCheck
|
||||
onClick={() => handleTimeRangeSelect(range)}
|
||||
>
|
||||
{range}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
interface ControlsProps {
|
||||
searchQuery?: string
|
||||
@@ -0,0 +1 @@
|
||||
export { Controls, default } from './controls'
|
||||
@@ -0,0 +1 @@
|
||||
export { default, SlackChannelSelector } from './slack-channel-selector'
|
||||
@@ -81,8 +81,8 @@ export function SlackChannelSelector({
|
||||
|
||||
if (!accountId) {
|
||||
return (
|
||||
<div className='rounded-[8px] border border-dashed p-3 text-center'>
|
||||
<p className='text-muted-foreground text-sm'>Select a Slack account first</p>
|
||||
<div className='rounded-[6px] border bg-[var(--surface-3)] p-[10px] text-center'>
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>Select a Slack account first</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export function SlackChannelSelector({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={value}
|
||||
@@ -106,11 +106,13 @@ export function SlackChannelSelector({
|
||||
error={fetchError}
|
||||
/>
|
||||
{selectedChannel && !fetchError && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
{selectedChannel.isPrivate ? 'Private' : 'Public'} channel: #{selectedChannel.name}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className='text-red-400 text-xs'>{error}</p>}
|
||||
{error && <p className='text-[11px] text-[var(--text-error)]'>{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SlackChannelSelector
|
||||
@@ -0,0 +1 @@
|
||||
export { default, WorkflowSelector } from './workflow-selector'
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Layers, X } from 'lucide-react'
|
||||
import { Button, Combobox, type ComboboxOption } from '@/components/emcn'
|
||||
import { Label, Skeleton } from '@/components/ui'
|
||||
import { X } from 'lucide-react'
|
||||
import { Badge, Combobox, type ComboboxOption } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
|
||||
interface WorkflowSelectorProps {
|
||||
workspaceId: string
|
||||
@@ -13,10 +13,10 @@ interface WorkflowSelectorProps {
|
||||
error?: string
|
||||
}
|
||||
|
||||
const ALL_WORKFLOWS_VALUE = '__all_workflows__'
|
||||
|
||||
/**
|
||||
* Multi-select workflow selector with "All Workflows" option.
|
||||
* Uses Combobox's built-in showAllOption for the "All Workflows" selection.
|
||||
* When allWorkflows is true, the array is empty and "All Workflows" is selected.
|
||||
*/
|
||||
export function WorkflowSelector({
|
||||
workspaceId,
|
||||
@@ -47,83 +47,48 @@ export function WorkflowSelector({
|
||||
}, [workspaceId])
|
||||
|
||||
const options: ComboboxOption[] = useMemo(() => {
|
||||
const workflowOptions = workflows.map((w) => ({
|
||||
return workflows.map((w) => ({
|
||||
label: w.name,
|
||||
value: w.id,
|
||||
}))
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'All Workflows',
|
||||
value: ALL_WORKFLOWS_VALUE,
|
||||
icon: Layers,
|
||||
},
|
||||
...workflowOptions,
|
||||
]
|
||||
}, [workflows])
|
||||
|
||||
/**
|
||||
* When allWorkflows is true, pass empty array so the "All" option is selected.
|
||||
* Otherwise, pass the selected workflow IDs.
|
||||
*/
|
||||
const currentValues = useMemo(() => {
|
||||
if (allWorkflows) {
|
||||
return [ALL_WORKFLOWS_VALUE]
|
||||
}
|
||||
return selectedIds
|
||||
return allWorkflows ? [] : selectedIds
|
||||
}, [allWorkflows, selectedIds])
|
||||
|
||||
/**
|
||||
* Handle multi-select changes from Combobox.
|
||||
* Empty array from showAllOption = all workflows selected.
|
||||
*/
|
||||
const handleMultiSelectChange = (values: string[]) => {
|
||||
const hasAllWorkflows = values.includes(ALL_WORKFLOWS_VALUE)
|
||||
const hadAllWorkflows = allWorkflows
|
||||
|
||||
if (hasAllWorkflows && !hadAllWorkflows) {
|
||||
// User selected "All Workflows" - clear individual selections
|
||||
if (values.length === 0) {
|
||||
onChange([], true)
|
||||
} else if (!hasAllWorkflows && hadAllWorkflows) {
|
||||
// User deselected "All Workflows" - switch to individual selection
|
||||
onChange(
|
||||
values.filter((v) => v !== ALL_WORKFLOWS_VALUE),
|
||||
false
|
||||
)
|
||||
} else {
|
||||
// Normal individual workflow selection/deselection
|
||||
onChange(
|
||||
values.filter((v) => v !== ALL_WORKFLOWS_VALUE),
|
||||
false
|
||||
)
|
||||
onChange(values, false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = (e: React.MouseEvent, id: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (id === ALL_WORKFLOWS_VALUE) {
|
||||
onChange([], false)
|
||||
} else {
|
||||
onChange(
|
||||
selectedIds.filter((i) => i !== id),
|
||||
false
|
||||
)
|
||||
}
|
||||
onChange(
|
||||
selectedIds.filter((i) => i !== id),
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
const selectedWorkflows = useMemo(() => {
|
||||
return workflows.filter((w) => selectedIds.includes(w.id))
|
||||
}, [workflows, selectedIds])
|
||||
|
||||
// Render overlay content showing selected items as tags
|
||||
const overlayContent = useMemo(() => {
|
||||
if (allWorkflows) {
|
||||
return (
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='pointer-events-auto h-6 gap-1 rounded-[6px] px-2 text-[11px]'
|
||||
onMouseDown={(e) => handleRemove(e, ALL_WORKFLOWS_VALUE)}
|
||||
>
|
||||
<Layers className='h-3 w-3' />
|
||||
All Workflows
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
return <span className='truncate text-[var(--text-primary)]'>All Workflows</span>
|
||||
}
|
||||
|
||||
if (selectedWorkflows.length === 0) {
|
||||
@@ -131,22 +96,22 @@ export function WorkflowSelector({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-1 overflow-hidden'>
|
||||
<div className='flex items-center gap-[4px] overflow-hidden'>
|
||||
{selectedWorkflows.slice(0, 2).map((w) => (
|
||||
<Button
|
||||
<Badge
|
||||
key={w.id}
|
||||
variant='outline'
|
||||
className='pointer-events-auto h-6 gap-1 rounded-[6px] px-2 text-[11px]'
|
||||
className='pointer-events-auto cursor-pointer gap-[4px] rounded-[6px] px-[8px] py-[2px] text-[11px]'
|
||||
onMouseDown={(e) => handleRemove(e, w.id)}
|
||||
>
|
||||
{w.name}
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
{selectedWorkflows.length > 2 && (
|
||||
<span className='flex h-6 items-center rounded-[6px] border px-2 text-[11px]'>
|
||||
<Badge variant='outline' className='rounded-[6px] px-[8px] py-[2px] text-[11px]'>
|
||||
+{selectedWorkflows.length - 2}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -154,16 +119,16 @@ export function WorkflowSelector({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<Label className='font-medium text-sm'>Workflows</Label>
|
||||
<Skeleton className='h-9 w-full rounded-[4px]' />
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>Workflows</span>
|
||||
<Skeleton className='h-[34px] w-full rounded-[6px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<Label className='font-medium text-sm'>Workflows</Label>
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>Workflows</span>
|
||||
<Combobox
|
||||
options={options}
|
||||
multiSelect
|
||||
@@ -174,10 +139,11 @@ export function WorkflowSelector({
|
||||
overlayContent={overlayContent}
|
||||
searchable
|
||||
searchPlaceholder='Search workflows...'
|
||||
showAllOption
|
||||
allOptionLabel='All Workflows'
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Select which workflows should trigger this notification
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowSelector
|
||||
@@ -0,0 +1 @@
|
||||
export { NotificationSettings } from './notifications'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
export { AutocompleteSearch } from './search'
|
||||
@@ -31,7 +31,7 @@ interface AutocompleteSearchProps {
|
||||
export function AutocompleteSearch({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search logs...',
|
||||
placeholder = 'Search',
|
||||
className,
|
||||
onOpenChange,
|
||||
}: AutocompleteSearchProps) {
|
||||
@@ -139,11 +139,11 @@ export function AutocompleteSearch({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const [dropdownWidth, setDropdownWidth] = useState(500)
|
||||
const [dropdownWidth, setDropdownWidth] = useState(400)
|
||||
useEffect(() => {
|
||||
const measure = () => {
|
||||
if (inputRef.current) {
|
||||
setDropdownWidth(inputRef.current.parentElement?.offsetWidth || 500)
|
||||
setDropdownWidth(inputRef.current.parentElement?.offsetWidth || 400)
|
||||
}
|
||||
}
|
||||
measure()
|
||||
@@ -181,15 +181,12 @@ export function AutocompleteSearch({
|
||||
}}
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<div className='relative flex h-9 w-[500px] items-center rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] transition-colors focus-within:border-[var(--surface-14)] focus-within:ring-1 focus-within:ring-ring hover:border-[var(--surface-14)] dark:bg-[var(--surface-9)] dark:hover:border-[var(--surface-13)]'>
|
||||
<div className='relative flex h-[32px] w-[400px] items-center rounded-[8px] bg-[var(--surface-5)]'>
|
||||
{/* Search Icon */}
|
||||
<Search
|
||||
className='ml-2.5 h-4 w-4 flex-shrink-0 text-muted-foreground'
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Search className='mr-[6px] ml-[8px] h-[14px] w-[14px] flex-shrink-0 text-[var(--text-subtle)]' />
|
||||
|
||||
{/* Scrollable container for badges */}
|
||||
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
|
||||
<div className='flex flex-1 items-center gap-[6px] overflow-x-auto pr-[6px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
|
||||
{/* Applied Filter Badges */}
|
||||
{appliedFilters.map((filter, index) => (
|
||||
<Button
|
||||
@@ -197,7 +194,7 @@ export function AutocompleteSearch({
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'h-6 flex-shrink-0 gap-1 rounded-[6px] px-2 text-[11px]',
|
||||
highlightedBadgeIndex === index && 'border-white dark:border-white'
|
||||
highlightedBadgeIndex === index && 'border'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
@@ -238,7 +235,7 @@ export function AutocompleteSearch({
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className='min-w-[100px] flex-1 border-0 bg-transparent font-sans text-foreground text-sm outline-none placeholder:text-[var(--text-muted)]'
|
||||
className='min-w-[80px] flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none outline-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0 md:text-sm'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -246,10 +243,10 @@ export function AutocompleteSearch({
|
||||
{(hasFilters || hasTextSearch) && (
|
||||
<button
|
||||
type='button'
|
||||
className='mr-2.5 flex h-5 w-5 flex-shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground'
|
||||
className='mr-[8px] ml-[6px] flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover:text-[var(--text-secondary)]'
|
||||
onClick={clearAll}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -290,7 +287,7 @@ export function AutocompleteSearch({
|
||||
|
||||
{sections.map((section) => (
|
||||
<div key={section.title}>
|
||||
<div className='border-border/50 border-t px-3 py-1.5 font-medium text-[11px] text-[var(--text-muted)] uppercase tracking-wide'>
|
||||
<div className='border-[var(--divider)] border-t px-3 py-1.5 font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
|
||||
{section.title}
|
||||
</div>
|
||||
{section.suggestions.map((suggestion) => {
|
||||
@@ -345,7 +342,7 @@ export function AutocompleteSearch({
|
||||
// Single section layout
|
||||
<div className='py-1'>
|
||||
{suggestionType === 'filters' && (
|
||||
<div className='border-border/50 border-b px-3 py-1.5 font-medium text-[11px] text-[var(--text-muted)] uppercase tracking-wide'>
|
||||
<div className='border-[var(--divider)] border-b px-3 py-1.5 font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
|
||||
SUGGESTED FILTERS
|
||||
</div>
|
||||
)}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { Controls } from './components/controls'
|
||||
export { NotificationSettings } from './components/notifications'
|
||||
export { AutocompleteSearch } from './components/search'
|
||||
export { LogsToolbar } from './logs-toolbar'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user