feat: new search bar component (#2909)

* fix: compute payload size correctly for pg_notify

* refactor: pull search bar into its own component

* just use tab for autocomplete

* fix: log line typing

* Refactor Search Bar (#2964)

* Add empty search state

* V1 table layout logviewer

* Add temp hatchet-worker for testing

* Fix log css grid when expanded

* undo search notfound logic, needs API logic

* Rework workflow example

* use correct info color

* better table headers

* Add back ansi formatting

* Allow enter along with tab to traverse chips

* remove tutorial

* Add syntax color to search chips

* styling progress

* styling progress

* constrain width

* Add rel time

* Readd flag conditional

* remove hatchet-worker, feature flag, and cypress test

* remove tenant hook

---------

Co-authored-by: Alexander Belanger <alexander@hatchet.run>

* fix: remove ansi-to-html, review feedback

---------

Co-authored-by: Sebastian Graz <graz@live.se>
This commit is contained in:
abelanger5
2026-02-17 20:47:20 -08:00
committed by GitHub
parent e13721b24a
commit 73ef4747e7
12 changed files with 1324 additions and 353 deletions
-1
View File
@@ -53,7 +53,6 @@
"@tanstack/react-router-devtools": "^1.141.0",
"@tanstack/react-table": "^8.21.2",
"@types/lodash": "^4.17.20",
"ansi-to-html": "^0.7.2",
"axios": "^1.12.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
-17
View File
@@ -111,9 +111,6 @@ importers:
'@types/lodash':
specifier: ^4.17.20
version: 4.17.20
ansi-to-html:
specifier: ^0.7.2
version: 0.7.2
axios:
specifier: ^1.12.0
version: 1.12.0
@@ -1877,11 +1874,6 @@ packages:
resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
engines: {node: '>=12'}
ansi-to-html@0.7.2:
resolution: {integrity: sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==}
engines: {node: '>=8.0.0'}
hasBin: true
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
@@ -2366,9 +2358,6 @@ packages:
resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
engines: {node: '>=8.6'}
entities@2.2.0:
resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==}
es-abstract@1.24.0:
resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==}
engines: {node: '>= 0.4'}
@@ -6042,10 +6031,6 @@ snapshots:
ansi-styles@6.2.1: {}
ansi-to-html@0.7.2:
dependencies:
entities: 2.2.0
any-promise@1.3.0: {}
anymatch@3.1.3:
@@ -6578,8 +6563,6 @@ snapshots:
ansi-colors: 4.1.3
strip-ansi: 6.0.1
entities@2.2.0: {}
es-abstract@1.24.0:
dependencies:
array-buffer-byte-length: 1.0.2
@@ -0,0 +1,188 @@
import { CSSProperties } from 'react';
// ---- ANSI parser (adapted from react-logviewer/src/components/Utils/ansiparse.ts) ----
interface AnsiPart {
text: string;
foreground?: string;
background?: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
}
type AnsiState = Omit<AnsiPart, 'text'>;
const FOREGROUND_COLORS: Record<string, string> = {
'30': 'black',
'31': 'red',
'32': 'green',
'33': 'yellow',
'34': 'blue',
'35': 'magenta',
'36': 'cyan',
'37': 'white',
'90': 'grey',
};
const BACKGROUND_COLORS: Record<string, string> = {
'40': 'black',
'41': 'red',
'42': 'green',
'43': 'yellow',
'44': 'blue',
'45': 'magenta',
'46': 'cyan',
'47': 'white',
};
function ansiparse(str: string): AnsiPart[] {
let matchingControl: string | null = null;
let matchingData: string | null = null;
let matchingText = '';
let ansiState: string[] = [];
const result: AnsiPart[] = [];
let state: AnsiState = {};
for (let i = 0; i < str.length; i++) {
if (matchingControl !== null) {
if (matchingControl === '\x1b' && str[i] === '[') {
if (matchingText) {
result.push({ text: matchingText, ...state });
state = {};
matchingText = '';
}
matchingControl = null;
matchingData = '';
} else {
matchingText += matchingControl + str[i];
matchingControl = null;
}
continue;
}
if (matchingData !== null) {
if (str[i] === ';') {
ansiState.push(matchingData);
matchingData = '';
} else if (str[i] === 'm') {
ansiState.push(matchingData);
matchingData = null;
matchingText = '';
for (const code of ansiState) {
if (FOREGROUND_COLORS[code]) {
state.foreground = FOREGROUND_COLORS[code];
} else if (BACKGROUND_COLORS[code]) {
state.background = BACKGROUND_COLORS[code];
} else if (code === '39') {
delete state.foreground;
} else if (code === '49') {
delete state.background;
} else if (code === '1') {
state.bold = true;
} else if (code === '3') {
state.italic = true;
} else if (code === '4') {
state.underline = true;
} else if (code === '22') {
state.bold = false;
} else if (code === '23') {
state.italic = false;
} else if (code === '24') {
state.underline = false;
}
}
ansiState = [];
} else {
matchingData += str[i];
}
continue;
}
if (str[i] === '\x1b') {
matchingControl = str[i];
} else if (str[i] === '\u0008') {
// Backspace: erase previous character
if (matchingText.length) {
matchingText = matchingText.slice(0, -1);
} else if (result.length) {
const last = result[result.length - 1];
if (last.text.length === 1) {
result.pop();
} else {
last.text = last.text.slice(0, -1);
}
}
} else {
matchingText += str[i];
}
}
if (matchingText) {
result.push({ text: matchingText + (matchingControl ?? ''), ...state });
}
return result;
}
// ---- Color mapping to existing CSS variables ----
const FOREGROUND_CSS_VARS: Record<string, string> = {
red: 'var(--terminal-red)',
green: 'var(--terminal-green)',
yellow: 'var(--terminal-yellow)',
blue: 'var(--terminal-blue)',
magenta: 'var(--terminal-magenta)',
cyan: 'var(--terminal-cyan)',
};
function partStyle(part: AnsiPart): CSSProperties | undefined {
const style: CSSProperties = {};
let hasStyle = false;
const fg = part.foreground && FOREGROUND_CSS_VARS[part.foreground];
if (fg) {
style.color = fg;
hasStyle = true;
}
if (part.bold) {
style.fontWeight = 'bold';
hasStyle = true;
}
if (part.italic) {
style.fontStyle = 'italic';
hasStyle = true;
}
if (part.underline) {
style.textDecoration = 'underline';
hasStyle = true;
}
return hasStyle ? style : undefined;
}
// ---- Component ----
export function AnsiLine({ text }: { text: string }) {
const parts = ansiparse(text);
if (parts.length === 0) {
return null;
}
return (
<>
{parts.map((part, i) => {
const style = partStyle(part);
return style ? (
<span key={i} style={style}>
{part.text}
</span>
) : (
part.text
);
})}
</>
);
}
@@ -31,11 +31,22 @@ export interface AutocompleteState {
export function getAutocomplete(
query: string,
availableAttempts?: number[],
availableAttempts: number[],
): AutocompleteState {
const trimmed = query.trimEnd();
const lastWord = trimmed.split(' ').pop() || '';
// Check for trailing space FIRST - indicates user wants to add a new filter
// Don't check this if the query ends with a colon (e.g., "level:")
if (query.endsWith(' ') && !trimmed.endsWith(':')) {
return { mode: 'key', suggestions: FILTER_KEYS };
}
// Check for empty input
if (trimmed === '') {
return { mode: 'key', suggestions: FILTER_KEYS };
}
if (lastWord.startsWith('level:')) {
const partial = lastWord.slice(6).toLowerCase();
const suggestions = LOG_LEVELS.filter((level) =>
@@ -72,10 +83,6 @@ export function getAutocomplete(
return { mode: 'key', suggestions: matchingKeys };
}
if (trimmed === '' || query.endsWith(' ')) {
return { mode: 'key', suggestions: FILTER_KEYS };
}
return { mode: 'none', suggestions: [] };
}
@@ -1,21 +1,7 @@
import { getAutocomplete, applySuggestion } from './autocomplete';
import type { AutocompleteSuggestion } from './types';
import { useLogsContext } from './use-logs';
import { Button } from '@/components/v1/ui/button';
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from '@/components/v1/ui/command';
import { Input } from '@/components/v1/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/v1/ui/popover';
import { cn } from '@/lib/utils';
import { MagnifyingGlassIcon, Cross2Icon } from '@radix-ui/react-icons';
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { SearchBarWithFilters } from '@/components/v1/molecules/search-bar-with-filters/search-bar-with-filters';
export function LogSearchInput({
placeholder = 'Search logs...',
@@ -25,241 +11,25 @@ export function LogSearchInput({
className?: string;
}) {
const { queryString, setQueryString, availableAttempts } = useLogsContext();
const inputRef = useRef<HTMLInputElement>(null);
const blurTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState<number>();
const [localValue, setLocalValue] = useState(queryString);
useEffect(() => {
setLocalValue(queryString);
}, [queryString]);
const { suggestions } = getAutocomplete(localValue, availableAttempts);
const submitSearch = useCallback(() => {
setQueryString(localValue);
}, [localValue, setQueryString]);
const handleFilterChipClick = useCallback(
(filterKey: string) => {
const newValue = localValue ? `${localValue} ${filterKey}` : filterKey;
setLocalValue(newValue);
setIsOpen(true);
setSelectedIndex(undefined);
setTimeout(() => {
const input = inputRef.current;
if (input) {
input.focus();
input.setSelectionRange(newValue.length, newValue.length);
}
}, 0);
},
[localValue],
);
const handleSelect = useCallback(
(index: number) => {
const suggestion = suggestions[index];
if (suggestion) {
const newValue = applySuggestion(localValue, suggestion);
setLocalValue(newValue);
if (suggestion.type === 'value') {
setQueryString(newValue);
setIsOpen(false);
}
setSelectedIndex(undefined);
setTimeout(() => {
const input = inputRef.current;
if (input) {
input.focus();
input.setSelectionRange(newValue.length, newValue.length);
}
}, 0);
}
},
[localValue, suggestions, setQueryString],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
if (isOpen && suggestions.length > 0 && selectedIndex !== undefined) {
handleSelect(selectedIndex);
} else {
submitSearch();
setIsOpen(false);
}
return;
}
if (!isOpen || suggestions.length === 0) {
return;
}
if (e.key === 'Escape') {
setIsOpen(false);
setSelectedIndex(undefined);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((i) => {
if (i === undefined) {
return 0;
}
return i < suggestions.length - 1 ? i + 1 : 0;
});
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((i) => {
if (i === undefined) {
return suggestions.length - 1;
}
return i > 0 ? i - 1 : suggestions.length - 1;
});
} else if (e.key === 'Tab') {
if (selectedIndex !== undefined) {
e.preventDefault();
handleSelect(selectedIndex);
}
}
},
[isOpen, suggestions.length, selectedIndex, handleSelect, submitSearch],
);
return (
<div className={cn('space-y-2', className)}>
<Popover open={isOpen} modal={false}>
<PopoverTrigger asChild>
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
type="text"
value={localValue}
onChange={(e) => {
setLocalValue(e.target.value);
setIsOpen(true);
setSelectedIndex(undefined);
}}
onKeyDown={handleKeyDown}
onFocus={() => {
if (blurTimeoutRef.current) {
clearTimeout(blurTimeoutRef.current);
blurTimeoutRef.current = null;
}
setIsOpen(true);
setSelectedIndex(undefined);
}}
onBlur={() => {
blurTimeoutRef.current = setTimeout(() => {
setIsOpen(false);
blurTimeoutRef.current = null;
}, 200);
}}
placeholder={placeholder}
className="pl-9 pr-8"
/>
{localValue && (
<button
type="button"
onClick={() => {
setLocalValue('');
setQueryString('');
inputRef.current?.focus();
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<Cross2Icon className="h-4 w-4" />
</button>
)}
</div>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] min-w-[320px] p-0"
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{suggestions.length > 0 && (
<Command
value={
selectedIndex !== undefined
? suggestions[selectedIndex]?.value
: ''
}
>
<CommandList className="max-h-[300px]">
<CommandGroup>
{suggestions.map((suggestion, index) => (
<CommandItem
key={suggestion.value}
value={suggestion.value}
onSelect={() => handleSelect(index)}
className={cn(
'flex items-center justify-between',
selectedIndex !== undefined &&
index === selectedIndex &&
'bg-accent text-accent-foreground',
)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="flex items-center gap-2">
{suggestion.color && (
<div
className={cn(
suggestion.color,
'h-[6px] w-[6px] rounded-full',
)}
/>
)}
{suggestion.type === 'key' ? (
<code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">
{suggestion.label}:
</code>
) : (
<span className="font-mono text-sm">
{suggestion.label}
</span>
)}
</div>
{suggestion.description && (
<span className="text-xs text-muted-foreground truncate ml-2">
{suggestion.description}
</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
)}
<div
className={cn(
'flex items-center gap-2 px-3 py-2 text-xs',
suggestions.length > 0 && 'border-t',
)}
>
<span className="text-muted-foreground">Available filters:</span>
<Button
variant="outline"
size="xs"
className="h-auto px-2 py-0.5 text-xs"
onClick={() => handleFilterChipClick('level:')}
>
Level
</Button>
<Button
variant="outline"
size="xs"
className="h-auto px-2 py-0.5 text-xs"
onClick={() => handleFilterChipClick('attempt:')}
>
Attempt
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<SearchBarWithFilters<AutocompleteSuggestion, number[]>
value={queryString}
onChange={setQueryString}
onSubmit={setQueryString}
getAutocomplete={getAutocomplete}
applySuggestion={applySuggestion}
autocompleteContext={availableAttempts}
placeholder={placeholder}
className={className}
filterChips={[
{ key: 'level:', label: 'Level', description: 'Filter by log level' },
{
key: 'attempt:',
label: 'Attempt',
description: 'Filter by attempt number',
},
]}
/>
);
}
@@ -1,3 +1,5 @@
import type { SearchSuggestion } from '@/components/v1/molecules/search-bar-with-filters/search-bar-with-filters';
export const LOG_LEVELS = ['error', 'warn', 'info', 'debug'] as const;
export type LogLevel = (typeof LOG_LEVELS)[number];
@@ -17,7 +19,8 @@ export interface ParsedLogQuery {
errors: string[];
}
export interface AutocompleteSuggestion {
export interface AutocompleteSuggestion
extends SearchSuggestion<'key' | 'value'> {
type: 'key' | 'value';
label: string;
value: string;
@@ -1,65 +1,48 @@
import Terminal from './components/Terminal';
import { AnsiLine } from './ansi-line';
import { LogLine } from './log-search/use-logs';
import RelativeDate from '@/components/v1/molecules/relative-date';
import { V1TaskStatus } from '@/lib/api';
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { useMemo, useCallback, useRef, useState } from 'react';
const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
};
// ANSI color codes
const WHITE = '\x1b[37m'; // Regular white for timestamps (dimmer than bright white)
const RESET = '\x1b[0m'; // Reset to default
const LEVEL_STYLES: Record<string, { bg: string; text: string; dot: string }> =
{
error: {
bg: 'bg-red-500/10',
text: 'text-red-600 dark:text-red-400',
dot: 'bg-red-500',
},
warn: {
bg: 'bg-yellow-500/10',
text: 'text-yellow-600 dark:text-yellow-400',
dot: 'bg-yellow-500',
},
info: {
bg: 'bg-green-500/10',
text: 'text-green-600 dark:text-green-400',
dot: 'bg-green-500',
},
debug: {
bg: 'bg-gray-500/10',
text: 'text-gray-500 dark:text-gray-400',
dot: 'bg-gray-500',
},
};
// Nice theme colors for instance names (using bright ANSI colors)
const INSTANCE_COLORS = [
'\x1b[94m', // Bright blue
'\x1b[96m', // Bright cyan
'\x1b[95m', // Bright magenta
'\x1b[36m', // Cyan
'\x1b[34m', // Blue
'\x1b[35m', // Magenta
];
const hashString = (str: string): number => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash);
};
const getInstanceColor = (instance: string): string => {
const hash = hashString(instance);
return INSTANCE_COLORS[hash % INSTANCE_COLORS.length];
};
const formatLogLine = (log: LogLine): string => {
let line = '';
if (log.timestamp) {
const formattedTime = new Date(log.timestamp)
.toLocaleString('sv', DATE_FORMAT_OPTIONS)
.replace(',', '.')
.replace(' ', 'T');
line += `${WHITE}${formattedTime}${RESET} `;
}
if (log.instance) {
const color = getInstanceColor(log.instance);
line += `${color}[${log.instance}]${RESET} `;
}
line += log.line || '';
return line;
const formatTimestamp = (timestamp: string): string => {
return new Date(timestamp)
.toLocaleString('sv', DATE_FORMAT_OPTIONS)
.replace(',', '.');
};
export interface LogViewerProps {
@@ -87,6 +70,24 @@ function getEmptyStateMessage(taskStatus?: V1TaskStatus): string {
}
}
const LevelBadge = ({ level }: { level: string }) => {
const normalized = level.toLowerCase();
const style = LEVEL_STYLES[normalized] ?? LEVEL_STYLES.debug;
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[11px] font-medium leading-none uppercase tracking-wide',
style.bg,
style.text,
)}
>
<span className={cn('size-1.5 rounded-full', style.dot)} />
{normalized}
</span>
);
};
export function LogViewer({
logs,
onScrollToBottom,
@@ -95,27 +96,96 @@ export function LogViewer({
isLoading,
taskStatus,
}: LogViewerProps) {
const formattedLogs = useMemo(() => {
const [showRelativeTime, setShowRelativeTime] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const lastScrollTopRef = useRef(0);
const wasAtTopRef = useRef(true);
const wasInTopRegionRef = useRef(false);
const wasInBottomRegionRef = useRef(false);
const sortedLogs = useMemo(() => {
if (logs.length === 0) {
return '';
return [];
}
const sortedLogs = [...logs].sort((a, b) => {
return [...logs].sort((a, b) => {
if (!a.timestamp || !b.timestamp) {
return 0;
}
// descending
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
});
return sortedLogs.map(formatLogLine).join('\n');
}, [logs]);
const { hasInstance, hasAttempt } = useMemo(() => {
let hasInstance = false;
let hasAttempt = false;
for (const log of sortedLogs) {
if (log.instance) {
hasInstance = true;
}
if (log.attempt !== undefined) {
hasAttempt = true;
}
if (hasInstance && hasAttempt) {
break;
}
}
return { hasInstance, hasAttempt };
}, [sortedLogs]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = el;
const scrollableHeight = scrollHeight - clientHeight;
if (scrollableHeight <= 0) {
return;
}
const scrollPercentage = scrollTop / scrollableHeight;
const isScrollingUp = scrollTop < lastScrollTopRef.current;
const isScrollingDown = scrollTop > lastScrollTopRef.current;
const isAtTop = scrollPercentage < 0.05;
if (onAtTopChange && isAtTop !== wasAtTopRef.current) {
wasAtTopRef.current = isAtTop;
onAtTopChange(isAtTop);
}
const isInTopRegion = isScrollingUp && scrollPercentage < 0.3;
if (isInTopRegion && !wasInTopRegionRef.current && onScrollToTop) {
onScrollToTop();
}
wasInTopRegionRef.current = isInTopRegion;
const isInBottomRegion = isScrollingDown && scrollPercentage > 0.7;
if (isInBottomRegion && !wasInBottomRegionRef.current && onScrollToBottom) {
onScrollToBottom();
}
wasInBottomRegionRef.current = isInBottomRegion;
lastScrollTopRef.current = scrollTop;
}, [onScrollToTop, onScrollToBottom, onAtTopChange]);
const isRunning = taskStatus === V1TaskStatus.RUNNING;
// Build dynamic grid-template-columns
const gridCols = [
'auto', // timestamp
'72px', // level
hasInstance && 'minmax(100px, 200px)',
hasAttempt && 'auto',
'minmax(0, 1fr)', // message
]
.filter(Boolean)
.join(' ');
if (isLoading) {
return (
<div className="max-h-[25rem] min-h-[25rem] rounded-md relative overflow-hidden bg-[var(--terminal-bg)] flex items-center justify-center">
<div className="max-h-[25rem] min-h-[25rem] rounded-lg border bg-background flex items-center justify-center">
<span className="text-sm text-muted-foreground">Loading logs...</span>
</div>
);
@@ -124,7 +194,7 @@ export function LogViewer({
const isEmpty = logs.length === 0;
if (isEmpty && taskStatus !== undefined) {
return (
<div className="max-h-[25rem] min-h-[25rem] rounded-md relative overflow-hidden bg-[var(--terminal-bg)] flex items-center justify-center">
<div className="max-h-[25rem] min-h-[25rem] rounded-lg border bg-background flex items-center justify-center">
<span className="text-sm text-muted-foreground">
{getEmptyStateMessage(taskStatus)}
</span>
@@ -132,24 +202,100 @@ export function LogViewer({
);
}
// Column count for subgrid span
const colCount = 3 + (hasInstance ? 1 : 0) + (hasAttempt ? 1 : 0);
return (
<div className="relative">
<div className="relative rounded-lg border bg-background overflow-hidden">
{isRunning && (
<div className="absolute top-2 right-4 z-10 flex items-center gap-2 text-xs text-muted-foreground bg-[var(--terminal-bg)]/80 px-2 py-1 rounded">
<div className="absolute top-2 right-4 z-20 flex items-center gap-2 text-xs text-muted-foreground bg-background/80 px-2 py-1 rounded-md">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500" />
</span>
<span>Live</span>
</div>
)}
<Terminal
logs={formattedLogs}
onScrollToTop={onScrollToTop}
onScrollToBottom={onScrollToBottom}
onAtTopChange={onAtTopChange}
className="max-h-[25rem] min-h-[25rem] rounded-md relative overflow-hidden"
/>
<div
className="grid max-h-[25rem] min-h-[25rem] overflow-y-auto"
style={{
gridTemplateColumns: gridCols,
alignContent: 'start',
}}
ref={scrollRef}
onScroll={handleScroll}
>
{/* Header row */}
<div
className="col-span-full grid grid-cols-subgrid sticky top-0 z-10 bg-gradient-to-b from-background/95 to-background/0 from-75%"
style={{ gridColumn: `1 / span ${colCount}` }}
>
<div
className="px-2 py-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground cursor-pointer select-none hover:text-foreground transition-colors"
onClick={() => setShowRelativeTime((prev) => !prev)}
>
Timestamp
</div>
<div className="px-2 py-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Level
</div>
{hasInstance && (
<div className="px-2 py-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Instance
</div>
)}
{hasAttempt && (
<div className="px-2 py-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Attempt
</div>
)}
<div className="px-2 py-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Message
</div>
</div>
{/* Data rows */}
{sortedLogs.map((log) => (
<div
key={`${log.timestamp}-${log.instance ?? ''}-${log.attempt ?? ''}`}
className="col-span-full items-baseline grid grid-cols-subgrid border-b border-border/40 hover:bg-muted/30 transition-colors group"
style={{ gridColumn: `1 / span ${colCount}` }}
>
<div className="px-3 py-1.5 font-mono text-xs text-muted-foreground whitespace-nowrap tabular-nums">
{log.timestamp ? (
showRelativeTime ? (
<RelativeDate date={log.timestamp} />
) : (
formatTimestamp(log.timestamp)
)
) : (
'—'
)}
</div>
<div className="px-3 py-1.5 flex items-center">
{log.level ? (
<LevelBadge level={log.level} />
) : (
<span className="text-xs text-muted-foreground/50"></span>
)}
</div>
{hasInstance && (
<div className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate">
{log.instance || '—'}
</div>
)}
{hasAttempt && (
<div className="px-3 py-1.5 font-mono text-xs text-muted-foreground text-center tabular-nums">
{log.attempt ?? '—'}
</div>
)}
<div className="px-3 py-1.5 font-mono text-xs text-foreground truncate group-hover:whitespace-normal group-hover:break-words">
<AnsiLine text={log.line ?? ''} />
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,306 @@
# Filter Examples
## Time Range Filter
For date/time picker inputs:
```tsx
import { CustomFilterInputProps } from './search-bar-with-filters';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
function TimeRangeFilterInput({
filterKey: _filterKey,
currentValue,
onComplete,
onCancel,
}: CustomFilterInputProps) {
const [start, setStart] = useState('');
const [end, setEnd] = useState('');
return (
<div className="p-4 space-y-3">
<div className="text-sm font-medium">Select Time Range</div>
<Input
type="datetime-local"
value={start}
onChange={(e) => setStart(e.target.value)}
/>
<Input
type="datetime-local"
value={end}
onChange={(e) => setEnd(e.target.value)}
/>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onCancel}>
Cancel
</Button>
<Button
size="sm"
onClick={() => onComplete(`${start},${end}`)}
disabled={!start || !end}
>
Apply
</Button>
</div>
</div>
);
}
// Complete usage with SearchBarWithFilters:
function MySearchComponent() {
const [query, setQuery] = useState('');
const getAutocomplete = (q: string) => {
const lastWord = q.split(' ').pop() || '';
// Show time range picker when "time:" is typed
if (lastWord.startsWith('time:')) {
return {
suggestions: [
{
type: 'custom-input',
label: 'Select time range',
value: 'time:',
customInput: TimeRangeFilterInput,
},
],
};
}
// Return other suggestions...
return { suggestions: [] };
};
const applySuggestion = (q: string, suggestion: any) => {
// Apply the time range value
return q + suggestion.value;
};
return (
<SearchBarWithFilters
value={query}
onChange={setQuery}
getAutocomplete={getAutocomplete}
applySuggestion={applySuggestion}
filterChips={[
{
key: 'time:',
label: 'Time Range',
customInput: TimeRangeFilterInput,
requiresCustomInput: true,
},
]}
/>
);
}
```
## Multi-Select Filter
For selecting multiple values:
```tsx
import { Checkbox } from '@/components/ui/checkbox';
function MultiSelectFilterInput({
currentValue,
onComplete,
onCancel,
}: CustomFilterInputProps) {
const options = [
{ value: 'active', label: 'Active', color: 'bg-green-500' },
{ value: 'pending', label: 'Pending', color: 'bg-yellow-500' },
{ value: 'failed', label: 'Failed', color: 'bg-red-500' },
];
const [selected, setSelected] = useState<string[]>(
currentValue ? currentValue.split(',') : [],
);
return (
<div className="p-4 space-y-3 min-w-[250px]">
<div className="text-sm font-medium">Select Statuses</div>
<div className="space-y-2">
{options.map((option) => (
<div key={option.value} className="flex items-center gap-2">
<Checkbox
checked={selected.includes(option.value)}
onCheckedChange={() => {
setSelected((prev) =>
prev.includes(option.value)
? prev.filter((v) => v !== option.value)
: [...prev, option.value],
);
}}
/>
<div className={`h-2 w-2 rounded-full ${option.color}`} />
<span className="text-sm">{option.label}</span>
</div>
))}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={onCancel}>
Cancel
</Button>
<Button
size="sm"
onClick={() => onComplete(selected.join(','))}
disabled={selected.length === 0}
>
Apply ({selected.length})
</Button>
</div>
</div>
);
}
// Complete usage with SearchBarWithFilters:
function MySearchComponent() {
const [query, setQuery] = useState('');
const getAutocomplete = (q: string) => {
const lastWord = q.split(' ').pop() || '';
// Show multi-select when "status:" is typed
if (lastWord.startsWith('status:')) {
return {
suggestions: [
{
type: 'multi-select',
label: 'Select statuses',
value: 'status:',
customInput: MultiSelectFilterInput,
},
],
};
}
// Return other suggestions...
return { suggestions: [] };
};
const applySuggestion = (q: string, suggestion: any) => {
// Apply the selected statuses
return q + suggestion.value;
};
return (
<SearchBarWithFilters
value={query}
onChange={setQuery}
getAutocomplete={getAutocomplete}
applySuggestion={applySuggestion}
filterChips={[
{
key: 'status:',
label: 'Status',
customInput: MultiSelectFilterInput,
requiresCustomInput: true,
},
]}
/>
);
}
```
## Numeric Range Filter
For port ranges, counts, etc:
```tsx
function RangeFilterInput({
filterKey,
currentValue,
onComplete,
onCancel,
}: CustomFilterInputProps) {
const [min, setMin] = useState(0);
const [max, setMax] = useState(0);
return (
<div className="p-4 space-y-3">
<div className="text-sm font-medium">
Select {filterKey.replace(':', '')} Range
</div>
<div className="flex items-center gap-2">
<input
type="number"
value={min || ''}
onChange={(e) => setMin(Number(e.target.value))}
placeholder="Min"
className="flex-1 px-3 py-2 text-sm border rounded-md"
/>
<span className="text-muted-foreground">to</span>
<input
type="number"
value={max || ''}
onChange={(e) => setMax(Number(e.target.value))}
placeholder="Max"
className="flex-1 px-3 py-2 text-sm border rounded-md"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={onCancel}>
Cancel
</Button>
<Button
size="sm"
onClick={() => onComplete(`${min}-${max}`)}
disabled={!min || !max || min > max}
>
Apply
</Button>
</div>
</div>
);
}
// Complete usage with SearchBarWithFilters:
function MySearchComponent() {
const [query, setQuery] = useState('');
const getAutocomplete = (q: string) => {
const lastWord = q.split(' ').pop() || '';
// Show range input when "port:" is typed
if (lastWord.startsWith('port:')) {
return {
suggestions: [
{
type: 'range',
label: 'Enter port range',
value: 'port:',
customInput: RangeFilterInput,
},
],
};
}
// Return other suggestions...
return { suggestions: [] };
};
const applySuggestion = (q: string, suggestion: any) => {
// Apply the port range
return q + suggestion.value;
};
return (
<SearchBarWithFilters
value={query}
onChange={setQuery}
getAutocomplete={getAutocomplete}
applySuggestion={applySuggestion}
filterChips={[
{
key: 'port:',
label: 'Port Range',
customInput: RangeFilterInput,
requiresCustomInput: true,
},
]}
/>
);
}
```
@@ -0,0 +1,548 @@
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from '@/components/v1/ui/command';
import { Input } from '@/components/v1/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/v1/ui/popover';
import { cn } from '@/lib/utils';
import { MagnifyingGlassIcon, Cross2Icon } from '@radix-ui/react-icons';
import React, {
useState,
useRef,
useCallback,
useEffect,
useMemo,
} from 'react';
export interface SearchSuggestion<TType extends string = string> {
type: TType;
label: string;
value: string;
description?: string;
color?: string;
metadata?: Record<string, unknown>;
}
export interface AutocompleteResult<TSuggestion extends SearchSuggestion> {
suggestions: TSuggestion[];
mode?: string;
}
export interface FilterChip {
key: string;
label: string;
description?: string;
// For complex filters that need custom input components
customInput?: React.ComponentType<CustomFilterInputProps>;
// For filters that need multi-step input (e.g., time ranges)
requiresCustomInput?: boolean;
}
export interface CustomFilterInputProps {
filterKey: string;
currentValue: string;
onComplete: (value: string) => void;
onCancel: () => void;
}
export interface SearchBarWithFiltersProps<
TSuggestion extends SearchSuggestion,
TContext,
> {
// Value control
value: string;
/**
* Called when a complete filter value is selected from the dropdown, or when
* the input is cleared. NOT called on every keystroke — the component buffers
* input locally and only notifies the parent on meaningful state changes.
*
* Also serves as the fallback for Enter when `onSubmit` is not provided.
*/
onChange: (value: string) => void;
/**
* Called when Enter is pressed while the dropdown is closed or has no
* suggestions (i.e. the user wants to search with the current free-text
* value as-is). When the dropdown is open, Enter autocompletes instead.
*
* If omitted, `onChange` is used as a fallback.
*/
onSubmit?: (value: string) => void;
// Autocomplete functions (domain-specific)
getAutocomplete: (
query: string,
context: TContext,
) => AutocompleteResult<TSuggestion>;
applySuggestion: (query: string, suggestion: TSuggestion) => string;
autocompleteContext: TContext;
// Custom rendering
renderSuggestion?: (
suggestion: TSuggestion,
isSelected: boolean,
) => React.ReactNode;
// Filter chips
filterChips?: FilterChip[];
onFilterChipClick?: (filterKey: string) => void;
// UI customization
placeholder?: string;
className?: string;
searchIcon?: React.ReactNode;
clearIcon?: React.ReactNode;
popoverClassName?: string;
}
// ============================================================================
// Filter highlight helpers
// ============================================================================
/** Colour for the filter key prefix (e.g. "level" in level:error) */
const FILTER_KEY_COLOR = 'hsl(var(--brand))';
/** Colour for the filter value suffix (e.g. "error" in level:error) */
const FILTER_VALUE_COLOR =
'color-mix(in srgb, hsl(var(--brand)) 70%, hsl(var(--foreground)))';
interface TextSegment {
text: string;
isFilter: boolean;
}
/**
* Splits the input value into segments, tagging recognised filter tokens
* (e.g. `level:error`) so they can be rendered with custom colours.
*/
const parseFilterSegments = (
value: string,
filterChips: FilterChip[],
): TextSegment[] => {
if (!value) {
return [];
}
const segments: TextSegment[] = [];
// Split on whitespace runs while preserving them
const parts = value.split(/(\s+)/);
for (const part of parts) {
if (/^\s+$/.test(part)) {
segments.push({ text: part, isFilter: false });
continue;
}
const isFilter = filterChips.some((chip) =>
part.toLowerCase().startsWith(chip.key.toLowerCase()),
);
segments.push({ text: part, isFilter });
}
return segments;
};
// ============================================================================
// Component
// ============================================================================
export function SearchBarWithFilters<
TSuggestion extends SearchSuggestion,
TContext,
>({
value,
onChange,
onSubmit,
getAutocomplete,
applySuggestion,
autocompleteContext,
renderSuggestion,
filterChips,
// onFilterChipClick,
placeholder = 'Search...',
className,
searchIcon,
clearIcon,
popoverClassName,
}: SearchBarWithFiltersProps<TSuggestion, TContext>) {
const inputRef = useRef<HTMLInputElement>(null);
const blurTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const justSelectedRef = useRef(false);
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState<number>();
const [localValue, setLocalValue] = useState(value);
const prevSuggestionsRef = useRef<TSuggestion[]>([]);
const prevLocalValueRef = useRef(value);
const overlayInnerRef = useRef<HTMLDivElement>(null);
const hasColoredFilters = !!filterChips?.length;
useEffect(() => {
setLocalValue(value);
}, [value]);
// Clear any pending blur timeout when the component unmounts
useEffect(() => {
return () => {
if (blurTimeoutRef.current) {
clearTimeout(blurTimeoutRef.current);
}
};
}, []);
const segments = useMemo(
() =>
hasColoredFilters ? parseFilterSegments(localValue, filterChips) : [],
[localValue, filterChips, hasColoredFilters],
);
// Keep the highlight overlay horizontally in sync with the native input scroll
useEffect(() => {
const input = inputRef.current;
if (!input || !hasColoredFilters) {
return;
}
const syncScroll = () => {
if (overlayInnerRef.current) {
overlayInnerRef.current.style.transform = `translateX(-${input.scrollLeft}px)`;
}
};
input.addEventListener('scroll', syncScroll);
syncScroll();
return () => input.removeEventListener('scroll', syncScroll);
}, [localValue, hasColoredFilters]);
const { suggestions } = useMemo(
() => getAutocomplete(localValue, autocompleteContext),
[getAutocomplete, localValue, autocompleteContext],
);
// Reset selection when suggestions change (e.g., from keys to values after selecting a key)
useEffect(() => {
const suggestionsChanged =
prevSuggestionsRef.current.length !== suggestions.length ||
prevSuggestionsRef.current.some(
(prev, i) => prev.value !== suggestions[i]?.value,
);
if (suggestionsChanged) {
// Reset selection - user must explicitly navigate with arrows
setSelectedIndex(undefined);
prevSuggestionsRef.current = suggestions;
}
}, [suggestions]);
// Open dropdown when space is added (for adding new filters)
useEffect(() => {
const justAddedSpace =
localValue.endsWith(' ') &&
!prevLocalValueRef.current.endsWith(' ') &&
localValue.length > prevLocalValueRef.current.length;
if (justAddedSpace && suggestions.length > 0) {
setIsOpen(true);
}
prevLocalValueRef.current = localValue;
}, [localValue, suggestions.length]);
const submitSearch = useCallback(() => {
if (onSubmit) {
onSubmit(localValue);
} else {
onChange(localValue);
}
}, [localValue, onChange, onSubmit]);
const handleSelect = useCallback(
(index: number) => {
const suggestion = suggestions[index];
if (suggestion) {
const newValue = applySuggestion(localValue, suggestion);
setLocalValue(newValue);
// Only update parent state for 'value' type suggestions (complete filters)
// This triggers the actual search
if (suggestion.type === 'value') {
onChange(newValue);
setIsOpen(false);
// Mark that we just selected a value to prevent reopening on refocus
justSelectedRef.current = true;
}
// For 'key' type suggestions, don't update parent - user is still building the filter
// Keep dropdown open for value selection
setTimeout(() => {
const input = inputRef.current;
if (input) {
input.focus();
input.setSelectionRange(newValue.length, newValue.length);
}
// Reset the flag after focus is restored
setTimeout(() => {
justSelectedRef.current = false;
}, 50);
}, 0);
}
},
[localValue, suggestions, applySuggestion, onChange],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
// If the dropdown is open with suggestions, Enter autocompletes (like Tab)
// Otherwise, Enter submits the search
if (isOpen && suggestions.length > 0) {
const indexToApply = selectedIndex !== undefined ? selectedIndex : 0;
if (suggestions[indexToApply]) {
handleSelect(indexToApply);
}
} else {
submitSearch();
setIsOpen(false);
}
return;
}
// Don't handle space specially - let onChange detect it and handle opening
// This ensures suggestions have updated before opening dropdown
if (!isOpen || suggestions.length === 0) {
return;
}
if (e.key === 'Escape') {
setIsOpen(false);
setSelectedIndex(undefined);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((i) => {
if (i === undefined) {
return 0;
}
return i < suggestions.length - 1 ? i + 1 : 0;
});
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((i) => {
if (i === undefined) {
return suggestions.length - 1;
}
return i > 0 ? i - 1 : suggestions.length - 1;
});
} else if (e.key === 'Tab') {
e.preventDefault();
// Tab autocompletes - apply the selected suggestion or first one if none selected
const indexToApply = selectedIndex !== undefined ? selectedIndex : 0;
if (suggestions[indexToApply]) {
handleSelect(indexToApply);
}
}
},
[isOpen, suggestions, selectedIndex, handleSelect, submitSearch],
);
const defaultRenderSuggestion = useCallback(
(suggestion: TSuggestion, isSelected: boolean) => (
<div
className={cn(
'flex items-center justify-between w-full',
isSelected && 'text-accent-foreground',
)}
>
<div className="flex items-center gap-2">
{suggestion.color && (
<div
className={cn(suggestion.color, 'h-[6px] w-[6px] rounded-full')}
/>
)}
{suggestion.type === 'key' ? (
<code className="py-0.5 text-xs font-mono">
{suggestion.label}:
</code>
) : (
<span className="py-0.5 font-mono text-xs">{suggestion.label}</span>
)}
</div>
{suggestion.description && (
<span className="text-xs text-muted-foreground truncate ml-2">
{suggestion.description}
</span>
)}
</div>
),
[],
);
const renderSuggestionContent = renderSuggestion || defaultRenderSuggestion;
const handleClear = useCallback(() => {
setLocalValue('');
// Clear the search by notifying parent with empty string
onChange('');
inputRef.current?.focus();
setIsOpen(false);
}, [onChange]);
return (
<div className={cn('space-y-2', className)}>
<Popover open={isOpen && suggestions.length > 0} modal={false}>
<PopoverTrigger asChild>
<div className="relative">
{searchIcon || (
<MagnifyingGlassIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
)}
<Input
ref={inputRef}
type="text"
value={localValue}
onChange={(e) => {
// Only update local state for autocomplete
// Don't trigger parent onChange until Enter is pressed or value suggestion is selected
const newValue = e.target.value;
setLocalValue(newValue);
// Open dropdown when user types (if there are suggestions)
if (newValue.length > 0) {
setIsOpen(true);
}
}}
onKeyDown={handleKeyDown}
onFocus={() => {
if (blurTimeoutRef.current) {
clearTimeout(blurTimeoutRef.current);
blurTimeoutRef.current = null;
}
// Open dropdown on focus to show available filters
// But not if we just selected a value (prevents reopening after click)
if (!justSelectedRef.current) {
setIsOpen(true);
}
}}
onBlur={() => {
blurTimeoutRef.current = setTimeout(() => {
setIsOpen(false);
blurTimeoutRef.current = null;
}, 200);
}}
placeholder={placeholder}
className={cn(
'pl-9 pr-8',
hasColoredFilters && 'text-transparent',
)}
style={
hasColoredFilters
? { caretColor: 'hsl(var(--foreground))' }
: undefined
}
data-cy="search-bar-input"
/>
{/* Coloured highlight overlay mirrors input text with filter colours */}
{hasColoredFilters && localValue && (
<div
className="absolute inset-[1px] pointer-events-none overflow-hidden flex items-center pl-9 pr-8"
aria-hidden="true"
>
<div
ref={overlayInnerRef}
className="text-sm text-foreground whitespace-pre"
>
{segments.map((segment, i) => {
if (!segment.isFilter) {
return <span key={i}>{segment.text}</span>;
}
const colonIdx = segment.text.indexOf(':');
const prefix = segment.text.slice(0, colonIdx + 1);
const suffix = segment.text.slice(colonIdx + 1);
return (
<span key={i}>
<span style={{ color: FILTER_KEY_COLOR }}>
{prefix}
</span>
{suffix && (
<span style={{ color: FILTER_VALUE_COLOR }}>
{suffix}
</span>
)}
</span>
);
})}
</div>
</div>
)}
{localValue && (
<button
type="button"
onClick={handleClear}
aria-label="Clear search"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
data-cy="search-bar-clear"
>
{clearIcon || <Cross2Icon className="h-4 w-4" />}
</button>
)}
</div>
</PopoverTrigger>
<PopoverContent
className={cn(
'w-[var(--radix-popover-trigger-width)] min-w-[320px] p-0 lg:max-w-[560px]',
popoverClassName,
)}
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{suggestions.length > 0 && (
<Command
key={suggestions.map((s) => s.value).join(',')}
value={
selectedIndex !== undefined
? suggestions[selectedIndex]?.value
: undefined
}
>
<CommandList
className="max-h-[300px] bg-muted/50"
data-cy="search-bar-suggestions"
>
<CommandGroup>
{suggestions.map((suggestion, index) => (
<CommandItem
key={`${suggestion.value}-${index}`}
value={suggestion.value}
onSelect={() => handleSelect(index)}
className={cn(
'flex items-center justify-between aria-selected:bg-primary/5 text-primary/60',
selectedIndex !== undefined &&
index === selectedIndex &&
'text-accent-foreground',
)}
onMouseEnter={() => setSelectedIndex(index)}
data-cy={`search-bar-suggestion-${index}`}
>
{renderSuggestionContent(
suggestion,
selectedIndex === index,
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
)}
</PopoverContent>
</Popover>
</div>
);
}
+12
View File
@@ -44,6 +44,12 @@
--terminal-bg: #f8fafc;
--terminal-fg: #1e293b;
--terminal-red: #dc2626;
--terminal-green: #16a34a;
--terminal-yellow: #ca8a04;
--terminal-blue: #2563eb;
--terminal-magenta: #c026d3;
--terminal-cyan: #0891b2;
}
.dark {
@@ -83,6 +89,12 @@
--terminal-bg: #1e293b;
--terminal-fg: #e2e8f0;
--terminal-red: #f87171;
--terminal-green: #4ade80;
--terminal-yellow: #facc15;
--terminal-blue: #60a5fa;
--terminal-magenta: #e879f9;
--terminal-cyan: #22d3ee;
}
}
@@ -5,9 +5,6 @@ import {
} from '@/components/v1/cloud/logging/log-search/use-logs';
import { LogViewer } from '@/components/v1/cloud/logging/log-viewer';
import { V1TaskSummary } from '@/lib/api';
import useCloud from '@/pages/auth/hooks/use-cloud';
import { appRoutes } from '@/router';
import { useParams } from '@tanstack/react-router';
export function TaskRunLogs({
taskRun,
@@ -24,7 +21,6 @@ export function TaskRunLogs({
}
function TaskRunLogsContent() {
const { tenant: tenantId } = useParams({ from: appRoutes.tenantRoute.to });
const {
logs,
fetchOlderLogs,
@@ -33,8 +29,7 @@ function TaskRunLogsContent() {
isLoading,
taskStatus,
} = useLogsContext();
const { featureFlags } = useCloud(tenantId);
const isLogSearchEnabled = featureFlags?.enable_log_search === 'true';
const isLogSearchEnabled = true;
return (
<div className="my-4 flex flex-col gap-y-2">
+14
View File
@@ -147,6 +147,20 @@ func (c *ConfigLoader) InitDataLayer() (res *database.Layer, err error) {
conn.TypeMap().RegisterType(t)
t, err = conn.LoadType(ctx, "v1_log_line_level")
if err != nil {
return err
}
conn.TypeMap().RegisterType(t)
t, err = conn.LoadType(ctx, "_v1_log_line_level")
if err != nil {
return err
}
conn.TypeMap().RegisterType(t)
_, err = conn.Exec(ctx, "SET statement_timeout=30000")
return err