diff --git a/apps/tui/package.json b/apps/tui/package.json index 54009445..43ddb21a 100644 --- a/apps/tui/package.json +++ b/apps/tui/package.json @@ -13,13 +13,13 @@ "postpublish": "chmod +x dist/ccflare" }, "dependencies": { - "@ccflare/tui-core": "workspace:*", - "@ccflare/ui-common": "workspace:*", - "@ccflare/database": "workspace:*", - "@ccflare/core-di": "workspace:*", "@ccflare/config": "workspace:*", + "@ccflare/core-di": "workspace:*", + "@ccflare/database": "workspace:*", "@ccflare/logger": "workspace:*", "@ccflare/server": "workspace:*", + "@ccflare/tui-core": "workspace:*", + "@ccflare/ui-common": "workspace:*", "ink": "^6.0.0", "ink-select-input": "^6.0.0", "ink-spinner": "^5.0.0", diff --git a/apps/tui/src/components/MigrationProgress.tsx b/apps/tui/src/components/MigrationProgress.tsx new file mode 100644 index 00000000..2e8516c4 --- /dev/null +++ b/apps/tui/src/components/MigrationProgress.tsx @@ -0,0 +1,69 @@ +import type { MigrationProgress } from "@ccflare/database"; +import { Box, Text, useApp } from "ink"; +import Spinner from "ink-spinner"; + +interface MigrationProgressProps { + progress: MigrationProgress; +} + +// Simple progress bar component without external dependencies +function SimpleProgressBar({ percent }: { percent: number }) { + const width = 40; + const filled = Math.round(width * percent); + const empty = width - filled; + + return ( + + {"█".repeat(filled)} + {"░".repeat(empty)} + + ); +} + +export function MigrationProgressComponent({ + progress, +}: MigrationProgressProps) { + const { exit } = useApp(); + + // Exit if progress is complete + if (progress.percentage === 100) { + setTimeout(() => exit(), 500); + } + + return ( + + + + Database Migration in Progress + + + + + {progress.operation} + + + + + {progress.current.toLocaleString()} /{" "} + {progress.total.toLocaleString()} records + + + + + Progress: {progress.percentage}% + + + + + This is a one-time operation to enable full-text search. + + Please wait while we index your request data... + + ); +} diff --git a/apps/tui/src/main.ts b/apps/tui/src/main.ts index 0709a291..54dbe07a 100644 --- a/apps/tui/src/main.ts +++ b/apps/tui/src/main.ts @@ -2,7 +2,7 @@ import { Config } from "@ccflare/config"; import { CLAUDE_MODEL_IDS, NETWORK, shutdown } from "@ccflare/core"; import { container, SERVICE_KEYS } from "@ccflare/core-di"; -import { DatabaseFactory } from "@ccflare/database"; +import { DatabaseFactory, type MigrationProgress } from "@ccflare/database"; import { Logger } from "@ccflare/logger"; // Import server import startServer from "@ccflare/server"; @@ -11,6 +11,7 @@ import { parseArgs } from "@ccflare/tui-core"; import { render } from "ink"; import React from "react"; import { App } from "./App"; +import { MigrationProgressComponent } from "./components/MigrationProgress"; // Global singleton for auto-started server let runningServer: ReturnType | null = null; @@ -27,11 +28,32 @@ async function main() { container.registerInstance(SERVICE_KEYS.Config, new Config()); container.registerInstance(SERVICE_KEYS.Logger, new Logger("TUI")); - // Initialize database factory - DatabaseFactory.initialize(); + // Track migration progress + let migrationProgress: MigrationProgress | null = null; + let migrationComplete = false; + + // Initialize database factory with progress callback + DatabaseFactory.initialize(undefined, undefined, (progress) => { + migrationProgress = progress; + if (progress.percentage === 100) { + migrationComplete = true; + } + }); + + // Show migration progress if needed const dbOps = DatabaseFactory.getInstance(); container.registerInstance(SERVICE_KEYS.Database, dbOps); + // If migration is in progress, show progress UI first + if (migrationProgress && !migrationComplete) { + const { waitUntilExit } = render( + React.createElement(MigrationProgressComponent, { + progress: migrationProgress, + }), + ); + await waitUntilExit(); + } + const args = process.argv.slice(2); const parsed = parseArgs(args); diff --git a/packages/dashboard-web/src/api.ts b/packages/dashboard-web/src/api.ts index f712ec58..fba22c73 100644 --- a/packages/dashboard-web/src/api.ts +++ b/packages/dashboard-web/src/api.ts @@ -26,6 +26,47 @@ export type { RequestResponse, } from "@ccflare/types"; +// Search types +export interface SearchFilters { + accountId?: string; + method?: string; + path?: string; + statusCode?: number; + success?: boolean; + dateFrom?: string; + dateTo?: string; + model?: string; + agentUsed?: string; + limit?: number; + offset?: number; +} + +export interface SearchResult { + id: string; + timestamp: number; + method: string; + path: string; + accountUsed: string | null; + accountName: string | null; + statusCode: number | null; + success: boolean; + responseTime: number; + model: string | null; + agentUsed: string | null; + requestSnippet?: string; + responseSnippet?: string; + requestSnippets?: string[]; + responseSnippets?: string[]; + rank: number; +} + +export interface SearchResponse { + results: SearchResult[]; + total: number; + query: string; + filters: SearchFilters; +} + // Agent response interface export interface AgentsResponse { agents: Agent[]; @@ -297,6 +338,49 @@ class API extends HttpClient { throw error; } } + + async searchRequests( + query: string, + filters: SearchFilters = {}, + ): Promise { + const params = new URLSearchParams({ q: query }); + + if (filters.limit !== undefined) { + params.append("limit", filters.limit.toString()); + } + if (filters.offset !== undefined) { + params.append("offset", filters.offset.toString()); + } + if (filters.accountId) { + params.append("accountId", filters.accountId); + } + if (filters.method) { + params.append("method", filters.method); + } + if (filters.path) { + params.append("path", filters.path); + } + if (filters.statusCode !== undefined) { + params.append("statusCode", filters.statusCode.toString()); + } + if (filters.success !== undefined) { + params.append("success", filters.success.toString()); + } + if (filters.dateFrom) { + params.append("dateFrom", filters.dateFrom); + } + if (filters.dateTo) { + params.append("dateTo", filters.dateTo); + } + if (filters.model) { + params.append("model", filters.model); + } + if (filters.agentUsed) { + params.append("agentUsed", filters.agentUsed); + } + + return this.get(`/api/requests/search?${params}`); + } } export const api = new API(); diff --git a/packages/dashboard-web/src/components/RequestsTab.tsx b/packages/dashboard-web/src/components/RequestsTab.tsx index 7565e22e..a97d1000 100644 --- a/packages/dashboard-web/src/components/RequestsTab.tsx +++ b/packages/dashboard-web/src/components/RequestsTab.tsx @@ -13,13 +13,16 @@ import { Eye, Filter, Hash, + Loader2, RefreshCw, + Search, User, X, } from "lucide-react"; import { useState } from "react"; -import type { RequestPayload, RequestSummary } from "../api"; +import type { RequestPayload, RequestSummary, SearchResult } from "../api"; import { useRequests } from "../hooks/queries"; +import { useDebounceSearch } from "../hooks/useDebounceSearch"; import { useRequestStream } from "../hooks/useRequestStream"; import { CopyButton } from "./CopyButton"; import { RequestDetailsModal } from "./RequestDetailsModal"; @@ -61,6 +64,8 @@ export function RequestsTab() { const [statusCodeFilters, setStatusCodeFilters] = useState>( new Set(), ); + const [searchQuery, setSearchQuery] = useState(""); + const [_isSearchMode, setIsSearchMode] = useState(false); const { data: requestsData, @@ -72,6 +77,24 @@ export function RequestsTab() { // Enable real-time updates useRequestStream(200); + // Search functionality + const { + data: searchData, + isLoading: isSearchLoading, + isSearching, + hasSearched, + } = useDebounceSearch(searchQuery, { + accountId: accountFilter !== "all" ? accountFilter : undefined, + method: agentFilter !== "all" ? agentFilter : undefined, + statusCode: + statusCodeFilters.size === 1 + ? Number.parseInt(Array.from(statusCodeFilters)[0]) + : undefined, + dateFrom: dateFrom ? new Date(dateFrom).toISOString() : undefined, + dateTo: dateTo ? new Date(dateTo).toISOString() : undefined, + limit: 100, + }); + // Transform the data to match the expected structure const data = requestsData ? { @@ -233,6 +256,13 @@ export function RequestsTab() { setDateFrom(""); setDateTo(""); setStatusCodeFilters(new Set()); + setSearchQuery(""); + setIsSearchMode(false); + }; + + const clearSearch = () => { + setSearchQuery(""); + setIsSearchMode(false); }; const hasActiveFilters = @@ -242,6 +272,14 @@ export function RequestsTab() { dateTo || statusCodeFilters.size > 0; + const hasActiveSearch = searchQuery.trim().length > 0; + + // Determine what to display + const shouldShowSearch = hasActiveSearch && hasSearched; + const searchResults = searchData?.results || []; + const _displayData = shouldShowSearch ? null : data; // Use null when in search mode + const displayRequests = shouldShowSearch ? [] : filteredRequests; // Empty when in search mode + const decodeBase64 = (str: string | null): string => { if (!str) return "No data"; try { @@ -262,6 +300,164 @@ export function RequestsTab() { */ // copyRequest helper removed – handled inline by CopyButton + // Search Result Card Component + interface SearchResultCardProps { + result: SearchResult; + onClick: () => void; + } + + const SearchResultCard = ({ result, onClick }: SearchResultCardProps) => { + const getStatusCodeColor = (code: number) => { + if (code >= 200 && code < 300) return "text-green-600"; + if (code >= 400 && code < 500) return "text-yellow-600"; + if (code >= 500) return "text-red-600"; + return "text-gray-600"; + }; + + // Safe highlighting that only handles tags + const renderHighlightedText = (text: string) => { + // Only allow and tags for highlighting + const parts = text.split(/(<\/?mark>)/); + return parts + .map((part, index) => { + if (part === "") { + return null; // Skip opening tag + } + if (part === "") { + return null; // Skip closing tag + } + // Check if this part should be highlighted (between mark tags) + const isHighlighted = index > 0 && parts[index - 1] === ""; + if (isHighlighted) { + return ( + + {part} + + ); + } + return part; + }) + .filter(Boolean); + }; + + return ( + + + + ); + }; + if (loading) { return ( @@ -323,6 +519,35 @@ export function RequestsTab() { + {/* Search Input */} +
+
+ + { + setSearchQuery(e.target.value); + setIsSearchMode(e.target.value.trim().length > 0); + }} + className="pl-10 pr-10" + /> + {isSearching && ( + + )} + {hasActiveSearch && !isSearching && ( + + )} +
+
+ {/* Active Filters Display */} {hasActiveFilters && (
@@ -386,10 +611,24 @@ export function RequestsTab() { )} + {hasActiveSearch && ( + + "{searchQuery.substring(0, 20)} + {searchQuery.length > 20 ? "..." : ""}" + + + )}
- {filteredRequests.length} of {data?.requests.length || 0}{" "} - requests + {shouldShowSearch + ? `${searchResults.length} search results` + : `${filteredRequests.length} of ${data?.requests.length || 0} requests`}