mirror of
https://github.com/hatchet-dev/hatchet.git
synced 2026-04-21 17:28:59 -05:00
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:
@@ -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",
|
||||
|
||||
Generated
-17
@@ -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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
+548
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-6
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user