Files
simstudioai--sim/apps/sim/lib/logs/search-suggestions.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

682 lines
19 KiB
TypeScript

import type { Suggestion, SuggestionGroup } from '@/app/workspace/[workspaceId]/logs/types'
export interface FilterDefinition {
key: string
label: string
description: string
options: Array<{
value: string
label: string
description?: string
}>
acceptsCustomValue?: boolean
customValueHint?: string
}
export interface WorkflowData {
id: string
name: string
description?: string
}
export interface FolderData {
id: string
name: string
}
export interface TriggerData {
value: string
label: string
color: string
}
/**
* Generates current date examples for the date filter options.
*/
function getDateExamples() {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const firstOfMonth = `${year}-${month}-01`
const today = `${year}-${month}-${day}`
const yearMonth = `${year}-${month}`
return { today, firstOfMonth, year: String(year), yearMonth }
}
export const FILTER_DEFINITIONS: FilterDefinition[] = [
{
key: 'level',
label: 'Status',
description: 'Filter by log level',
options: [
{ value: 'error', label: 'Error', description: 'Error logs only' },
{ value: 'info', label: 'Info', description: 'Info logs only' },
],
},
{
key: 'cost',
label: 'Cost',
description: 'Filter by run cost',
options: [
{
value: '>0.01',
label: 'Over 2 credits',
description: 'Runs costing more than 2 credits',
},
{
value: '<0.005',
label: 'Under 1 credit',
description: 'Runs costing less than 1 credit',
},
{
value: '>0.05',
label: 'Over 10 credits',
description: 'Runs costing more than 10 credits',
},
{ value: '=0', label: 'Free', description: 'Free runs' },
{ value: '>0', label: 'Paid', description: 'Runs with cost' },
],
},
{
key: 'date',
label: 'Date',
description: 'Filter by date range',
options: (() => {
const { today, firstOfMonth, year, yearMonth } = getDateExamples()
return [
{ value: 'today', label: 'Today', description: "Today's logs" },
{ value: 'yesterday', label: 'Yesterday', description: "Yesterday's logs" },
{ value: 'this-week', label: 'This week', description: "This week's logs" },
{ value: 'last-week', label: 'Last week', description: "Last week's logs" },
{ value: 'this-month', label: 'This month', description: "This month's logs" },
{ value: today, label: 'Specific date', description: 'YYYY-MM-DD' },
{ value: yearMonth, label: 'Specific month', description: 'YYYY-MM' },
{ value: year, label: 'Specific year', description: 'YYYY' },
{
value: `${firstOfMonth}..${today}`,
label: 'Date range',
description: 'YYYY-MM-DD..YYYY-MM-DD',
},
]
})(),
},
{
key: 'duration',
label: 'Duration',
description: 'Filter by run duration',
options: [
{ value: '>5s', label: 'Over 5s', description: 'Runs longer than 5 seconds' },
{ value: '<1s', label: 'Under 1s', description: 'Runs shorter than 1 second' },
{ value: '>10s', label: 'Over 10s', description: 'Runs longer than 10 seconds' },
{ value: '>30s', label: 'Over 30s', description: 'Runs longer than 30 seconds' },
{ value: '<500ms', label: 'Under 0.5s', description: 'Very fast runs' },
],
},
]
export class SearchSuggestions {
private workflowsData: WorkflowData[]
private foldersData: FolderData[]
private triggersData: TriggerData[]
constructor(
workflowsData: WorkflowData[] = [],
foldersData: FolderData[] = [],
triggersData: TriggerData[] = []
) {
this.workflowsData = workflowsData
this.foldersData = foldersData
this.triggersData = triggersData
}
updateData(
workflowsData: WorkflowData[] = [],
foldersData: FolderData[] = [],
triggersData: TriggerData[] = []
) {
this.workflowsData = workflowsData
this.foldersData = foldersData
this.triggersData = triggersData
}
/**
* Get all triggers from registry data
*/
private getAllTriggers(): TriggerData[] {
return this.triggersData
}
/**
* Get suggestions based ONLY on current input (no cursor position!)
*/
getSuggestions(input: string): SuggestionGroup | null {
const trimmed = input.trim()
if (!trimmed) {
return this.getFilterKeysList()
}
if (trimmed.endsWith(':')) {
const key = trimmed.slice(0, -1)
return this.getFilterValues(key)
}
if (trimmed.includes(':')) {
const [key, partial] = trimmed.split(':')
return this.getFilterValues(key, partial)
}
return this.getMultiSectionResults(trimmed)
}
/**
* Get filter keys list (empty input state)
*/
private getFilterKeysList(): SuggestionGroup {
const suggestions: Suggestion[] = []
for (const filter of FILTER_DEFINITIONS) {
suggestions.push({
id: `filter-key-${filter.key}`,
value: `${filter.key}:`,
label: filter.label,
description: filter.description,
category: 'filters',
})
}
suggestions.push({
id: 'filter-key-trigger',
value: 'trigger:',
label: 'Trigger',
description: 'Filter by trigger type',
category: 'filters',
})
if (this.workflowsData.length > 0) {
suggestions.push({
id: 'filter-key-workflow',
value: 'workflow:',
label: 'Workflow',
description: 'Filter by workflow name',
category: 'filters',
})
}
if (this.foldersData.length > 0) {
suggestions.push({
id: 'filter-key-folder',
value: 'folder:',
label: 'Folder',
description: 'Filter by folder name',
category: 'filters',
})
}
suggestions.push({
id: 'filter-key-workflowId',
value: 'workflowId:',
label: 'Workflow ID',
description: 'Filter by workflow ID',
category: 'filters',
})
suggestions.push({
id: 'filter-key-executionId',
value: 'executionId:',
label: 'Run ID',
description: 'Filter by run ID',
category: 'filters',
})
return {
type: 'filter-keys',
suggestions,
}
}
/**
* Get filter values for a specific key
*/
private getFilterValues(key: string, partial = ''): SuggestionGroup | null {
const filterDef = FILTER_DEFINITIONS.find((f) => f.key === key)
if (filterDef) {
const suggestions: Suggestion[] = filterDef.options
.filter(
(opt) =>
!partial ||
opt.value.toLowerCase().includes(partial.toLowerCase()) ||
opt.label.toLowerCase().includes(partial.toLowerCase())
)
.map((opt) => ({
id: `filter-value-${key}-${opt.value}`,
value: `${key}:${opt.value}`,
label: opt.label,
description: opt.description,
category: key as Suggestion['category'],
}))
// Handle custom date input
if (key === 'date' && partial) {
const dateSuggestions = this.getDateSuggestions(partial)
if (dateSuggestions.length > 0) {
suggestions.unshift(...dateSuggestions)
}
}
return suggestions.length > 0
? {
type: 'filter-values',
filterKey: key,
suggestions,
}
: null
}
if (key === 'trigger') {
const allTriggers = this.getAllTriggers()
const suggestions = allTriggers
.filter((t) => !partial || t.label.toLowerCase().includes(partial.toLowerCase()))
.map((t) => ({
id: `filter-value-trigger-${t.value}`,
value: `trigger:${t.value}`,
label: t.label,
description: `${t.label}-triggered runs`,
category: 'trigger' as const,
color: t.color,
}))
return suggestions.length > 0
? {
type: 'filter-values',
filterKey: 'trigger',
suggestions,
}
: null
}
if (key === 'workflow') {
const suggestions = this.workflowsData
.filter((w) => !partial || w.name.toLowerCase().includes(partial.toLowerCase()))
.map((w) => ({
id: `filter-value-workflow-${w.id}`,
value: `workflow:"${w.name}"`,
label: w.name,
description: w.description,
category: 'workflow' as const,
}))
return suggestions.length > 0
? {
type: 'filter-values',
filterKey: 'workflow',
suggestions,
}
: null
}
if (key === 'folder') {
const suggestions = this.foldersData
.filter((f) => !partial || f.name.toLowerCase().includes(partial.toLowerCase()))
.map((f) => ({
id: `filter-value-folder-${f.id}`,
value: `folder:"${f.name}"`,
label: f.name,
category: 'folder' as const,
}))
return suggestions.length > 0
? {
type: 'filter-values',
filterKey: 'folder',
suggestions,
}
: null
}
return null
}
/**
* Get multi-section results for plain text
*/
private getMultiSectionResults(query: string): SuggestionGroup | null {
const sections: Array<{ title: string; suggestions: Suggestion[] }> = []
const allSuggestions: Suggestion[] = []
const showAllSuggestion: Suggestion = {
id: 'show-all',
value: query,
label: `Show all results for "${query}"`,
category: 'show-all',
}
allSuggestions.push(showAllSuggestion)
const matchingFilterValues = this.getMatchingFilterValues(query)
if (matchingFilterValues.length > 0) {
sections.push({
title: 'SUGGESTED FILTERS',
suggestions: matchingFilterValues,
})
allSuggestions.push(...matchingFilterValues)
}
const matchingTriggers = this.getMatchingTriggers(query)
if (matchingTriggers.length > 0) {
sections.push({
title: 'TRIGGERS',
suggestions: matchingTriggers,
})
allSuggestions.push(...matchingTriggers)
}
const matchingWorkflows = this.getMatchingWorkflows(query)
if (matchingWorkflows.length > 0) {
sections.push({
title: 'WORKFLOWS',
suggestions: matchingWorkflows,
})
allSuggestions.push(...matchingWorkflows)
}
const matchingFolders = this.getMatchingFolders(query)
if (matchingFolders.length > 0) {
sections.push({
title: 'FOLDERS',
suggestions: matchingFolders,
})
allSuggestions.push(...matchingFolders)
}
if (
matchingFilterValues.length === 0 &&
matchingTriggers.length === 0 &&
matchingWorkflows.length === 0 &&
matchingFolders.length === 0
) {
const filterKeys = this.getFilterKeysList()
if (filterKeys.suggestions.length > 0) {
sections.push({
title: 'SUGGESTED FILTERS',
suggestions: filterKeys.suggestions.slice(0, 5),
})
allSuggestions.push(...filterKeys.suggestions.slice(0, 5))
}
}
return allSuggestions.length > 0
? {
type: 'multi-section',
suggestions: allSuggestions,
sections,
}
: null
}
/**
* Get suggestions for custom date input
*/
private getDateSuggestions(partial: string): Suggestion[] {
const suggestions: Suggestion[] = []
// Pattern for year only: YYYY
const yearPattern = /^\d{4}$/
// Pattern for month only: YYYY-MM
const monthPattern = /^\d{4}-\d{2}$/
// Pattern for full date: YYYY-MM-DD
const fullDatePattern = /^\d{4}-\d{2}-\d{2}$/
// Pattern for partial date being typed
const partialDatePattern = /^\d{4}(-\d{0,2})?(-\d{0,2})?$/
// Pattern for date range: YYYY-MM-DD..YYYY-MM-DD (complete or partial)
const rangePattern = /^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/
const partialRangePattern = /^(\d{4}-\d{2}-\d{2})\.\.?$/
// Check if it's a complete date range
if (rangePattern.test(partial)) {
const [startDate, endDate] = partial.split('..')
suggestions.push({
id: `date-range-${partial}`,
value: `date:${partial}`,
label: `${this.formatDateLabel(startDate)} to ${this.formatDateLabel(endDate)}`,
description: 'Custom date range',
category: 'date',
})
return suggestions
}
// Check if it's a partial date range (has ..)
if (partialRangePattern.test(partial)) {
const startDate = partial.replace(/\.+$/, '')
suggestions.push({
id: `date-range-hint-${partial}`,
value: `date:${startDate}..`,
label: `${this.formatDateLabel(startDate)} to ...`,
description: 'Type end date (YYYY-MM-DD)',
category: 'date',
})
return suggestions
}
// Check if it's a year only (YYYY)
if (yearPattern.test(partial)) {
suggestions.push({
id: `date-year-${partial}`,
value: `date:${partial}`,
label: `Year ${partial}`,
description: 'All logs from this year',
category: 'date',
})
return suggestions
}
// Check if it's a month only (YYYY-MM)
if (monthPattern.test(partial)) {
const [year, month] = partial.split('-')
const monthNames = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]
const monthName = monthNames[Number.parseInt(month, 10) - 1] || month
suggestions.push({
id: `date-month-${partial}`,
value: `date:${partial}`,
label: `${monthName} ${year}`,
description: 'All logs from this month',
category: 'date',
})
return suggestions
}
// Check if it's a complete single date
if (fullDatePattern.test(partial)) {
const date = new Date(partial)
if (!Number.isNaN(date.getTime())) {
suggestions.push({
id: `date-single-${partial}`,
value: `date:${partial}`,
label: this.formatDateLabel(partial),
description: 'Single date',
category: 'date',
})
// Also suggest starting a range
suggestions.push({
id: `date-range-start-${partial}`,
value: `date:${partial}..`,
label: `${this.formatDateLabel(partial)} to ...`,
description: 'Start a date range',
category: 'date',
})
}
return suggestions
}
// Check if user is typing a date pattern
if (partialDatePattern.test(partial) && partial.length >= 4) {
suggestions.push({
id: 'date-custom-hint',
value: `date:${partial}`,
label: partial,
description: 'Continue typing: YYYY, YYYY-MM, or YYYY-MM-DD',
category: 'date',
})
}
return suggestions
}
/**
* Format a date string for display
*/
private formatDateLabel(dateStr: string): string {
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return dateStr
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
/**
* Match filter values across all definitions
*/
private getMatchingFilterValues(query: string): Suggestion[] {
if (!query.trim()) return []
const matches: Suggestion[] = []
const lowerQuery = query.toLowerCase()
for (const filterDef of FILTER_DEFINITIONS) {
for (const option of filterDef.options) {
if (
option.value.toLowerCase().includes(lowerQuery) ||
option.label.toLowerCase().includes(lowerQuery)
) {
matches.push({
id: `filter-match-${filterDef.key}-${option.value}`,
value: `${filterDef.key}:${option.value}`,
label: `${filterDef.label}: ${option.label}`,
description: option.description,
category: filterDef.key as any,
})
}
}
}
return matches.slice(0, 5)
}
/**
* Match triggers by label (core + integrations)
*/
private getMatchingTriggers(query: string): Suggestion[] {
if (!query.trim()) return []
const lowerQuery = query.toLowerCase()
const allTriggers = this.getAllTriggers()
const matches = allTriggers
.filter((trigger) => trigger.label.toLowerCase().includes(lowerQuery))
.sort((a, b) => {
const aLabel = a.label.toLowerCase()
const bLabel = b.label.toLowerCase()
if (aLabel === lowerQuery) return -1
if (bLabel === lowerQuery) return 1
if (aLabel.startsWith(lowerQuery) && !bLabel.startsWith(lowerQuery)) return -1
if (bLabel.startsWith(lowerQuery) && !aLabel.startsWith(lowerQuery)) return 1
return aLabel.localeCompare(bLabel)
})
.slice(0, 8)
.map((trigger) => ({
id: `trigger-match-${trigger.value}`,
value: `trigger:${trigger.value}`,
label: trigger.label,
description: `${trigger.label}-triggered runs`,
category: 'trigger' as const,
color: trigger.color,
}))
return matches
}
/**
* Match workflows by name/description
*/
private getMatchingWorkflows(query: string): Suggestion[] {
if (!query.trim() || this.workflowsData.length === 0) return []
const lowerQuery = query.toLowerCase()
const matches = this.workflowsData
.filter(
(workflow) =>
workflow.name.toLowerCase().includes(lowerQuery) ||
workflow.description?.toLowerCase().includes(lowerQuery)
)
.sort((a, b) => {
const aName = a.name.toLowerCase()
const bName = b.name.toLowerCase()
if (aName === lowerQuery) return -1
if (bName === lowerQuery) return 1
if (aName.startsWith(lowerQuery) && !bName.startsWith(lowerQuery)) return -1
if (bName.startsWith(lowerQuery) && !aName.startsWith(lowerQuery)) return 1
return aName.localeCompare(bName)
})
.slice(0, 8)
.map((workflow) => ({
id: `workflow-match-${workflow.id}`,
value: `workflow:"${workflow.name}"`,
label: workflow.name,
description: workflow.description,
category: 'workflow' as const,
}))
return matches
}
/**
* Match folders by name
*/
private getMatchingFolders(query: string): Suggestion[] {
if (!query.trim() || this.foldersData.length === 0) return []
const lowerQuery = query.toLowerCase()
const matches = this.foldersData
.filter((folder) => folder.name.toLowerCase().includes(lowerQuery))
.sort((a, b) => {
const aName = a.name.toLowerCase()
const bName = b.name.toLowerCase()
if (aName === lowerQuery) return -1
if (bName === lowerQuery) return 1
if (aName.startsWith(lowerQuery) && !bName.startsWith(lowerQuery)) return -1
if (bName.startsWith(lowerQuery) && !aName.startsWith(lowerQuery)) return 1
return aName.localeCompare(bName)
})
.slice(0, 8)
.map((folder) => ({
id: `folder-match-${folder.id}`,
value: `folder:"${folder.name}"`,
label: folder.name,
category: 'folder' as const,
}))
return matches
}
}