From 6602c5610c6d7d8a1179b05c9f86598c4acd8fee Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:32:56 -0600 Subject: [PATCH 1/4] feat(logs): add composable filter controls --- gui/src/i18n/de.ts | 20 +++++ gui/src/i18n/en.ts | 20 +++++ gui/src/i18n/fr.ts | 20 +++++ gui/src/i18n/ja.ts | 20 +++++ gui/src/i18n/ko.ts | 20 +++++ gui/src/i18n/ru.ts | 20 +++++ gui/src/i18n/tr.ts | 20 +++++ gui/src/i18n/zh-TW.ts | 20 +++++ gui/src/i18n/zh.ts | 20 +++++ gui/src/pages/Logs.tsx | 108 ++++++----------------- gui/src/pages/logs-filter-bar.tsx | 123 +++++++++++++++++++++++++++ gui/src/styles.css | 2 + gui/tests/logs-auto-refresh.test.tsx | 11 +-- gui/tests/logs-filter-bar.test.ts | 81 ++++++++++++++++++ 14 files changed, 419 insertions(+), 86 deletions(-) create mode 100644 gui/src/pages/logs-filter-bar.tsx create mode 100644 gui/tests/logs-filter-bar.test.ts diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 2f34948385..7840019d85 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -651,6 +651,26 @@ export const de: Record = { "logs.noRequests": "Noch keine Anfragen.", "logs.loadError": "Anfrageprotokolle konnten nicht geladen werden.", "logs.filter.surface.label": "Oberfläche", + "logs.filter.model.all": "Alle Modelle", + "logs.filter.provider.label": "Anbieter", + "logs.filter.provider.all": "Alle Anbieter", + "logs.filter.status.label": "Status", + "logs.filter.status.all": "Alle Status", + "logs.filter.status.success": "Erfolg (2xx)", + "logs.filter.status.errors": "Fehler (4xx/5xx)", + "logs.filter.time.label": "Zeit", + "logs.filter.time.all": "Alle Zeiten", + "logs.filter.time.15m": "Letzte 15 Min.", + "logs.filter.time.1h": "Letzte Stunde", + "logs.filter.time.24h": "Letzter Tag", + "logs.filter.speed.label": "Geschwindigkeit", + "logs.filter.speed.all": "Alle Geschwindigkeiten", + "logs.filter.speed.slow": "< 15 Tok/s", + "logs.filter.speed.medium": "15–< 50 Tok/s", + "logs.filter.speed.fast": "≥ 50 Tok/s", + "logs.filter.reset": "Filter zurücksetzen", + "logs.filter.showingCount": "{count} von {total} angezeigt", + "logs.noMatchingRequests": "Keine passenden Anfragen.", "logs.filter.surface.all": "Alle", "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 7a3a21b11a..60a6d29a39 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -684,6 +684,26 @@ export const en = { "logs.noRequests": "No requests yet.", "logs.loadError": "Could not load request logs.", "logs.filter.surface.label": "Surface", + "logs.filter.model.all": "All models", + "logs.filter.provider.label": "Provider", + "logs.filter.provider.all": "All providers", + "logs.filter.status.label": "Status", + "logs.filter.status.all": "All statuses", + "logs.filter.status.success": "Success (2xx)", + "logs.filter.status.errors": "Errors (4xx/5xx)", + "logs.filter.time.label": "Time", + "logs.filter.time.all": "All time", + "logs.filter.time.15m": "Last 15m", + "logs.filter.time.1h": "Last 1h", + "logs.filter.time.24h": "Last 1d", + "logs.filter.speed.label": "Speed", + "logs.filter.speed.all": "All speeds", + "logs.filter.speed.slow": "< 15 tok/s", + "logs.filter.speed.medium": "15–< 50 tok/s", + "logs.filter.speed.fast": "≥ 50 tok/s", + "logs.filter.reset": "Reset filters", + "logs.filter.showingCount": "Showing {count} of {total}", + "logs.noMatchingRequests": "No matching requests.", "logs.filter.surface.all": "All", "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e953217ede..1f5d60f9ea 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -665,6 +665,26 @@ export const fr: Record = { "logs.noRequests": "Aucune requête pour le moment.", "logs.loadError": "Impossible de charger les journaux des requêtes.", "logs.filter.surface.label": "Interface", + "logs.filter.model.all": "Tous les modèles", + "logs.filter.provider.label": "Fournisseur", + "logs.filter.provider.all": "Tous les fournisseurs", + "logs.filter.status.label": "Statut", + "logs.filter.status.all": "Tous les statuts", + "logs.filter.status.success": "Réussites (2xx)", + "logs.filter.status.errors": "Erreurs (4xx/5xx)", + "logs.filter.time.label": "Temps", + "logs.filter.time.all": "Toutes les périodes", + "logs.filter.time.15m": "15 dernières min", + "logs.filter.time.1h": "Dernière heure", + "logs.filter.time.24h": "Dernier jour", + "logs.filter.speed.label": "Vitesse", + "logs.filter.speed.all": "Toutes les vitesses", + "logs.filter.speed.slow": "< 15 jetons/s", + "logs.filter.speed.medium": "15–< 50 jetons/s", + "logs.filter.speed.fast": "≥ 50 jetons/s", + "logs.filter.reset": "Réinitialiser les filtres", + "logs.filter.showingCount": "{count} sur {total} affichées", + "logs.noMatchingRequests": "Aucune requête correspondante.", "logs.filter.surface.all": "Toutes", "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e0f8e317c3..1bc6819120 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -627,6 +627,26 @@ export const ja: Record = { "logs.noRequests": "まだリクエストがありません。", "logs.loadError": "リクエストログを読み込めませんでした。", "logs.filter.surface.label": "サーフェス", + "logs.filter.model.all": "すべてのモデル", + "logs.filter.provider.label": "プロバイダー", + "logs.filter.provider.all": "すべてのプロバイダー", + "logs.filter.status.label": "ステータス", + "logs.filter.status.all": "すべてのステータス", + "logs.filter.status.success": "成功 (2xx)", + "logs.filter.status.errors": "エラー (4xx/5xx)", + "logs.filter.time.label": "時間", + "logs.filter.time.all": "すべての時間", + "logs.filter.time.15m": "過去15分", + "logs.filter.time.1h": "過去1時間", + "logs.filter.time.24h": "過去1日", + "logs.filter.speed.label": "速度", + "logs.filter.speed.all": "すべての速度", + "logs.filter.speed.slow": "< 15 トークン/秒", + "logs.filter.speed.medium": "15–< 50 トークン/秒", + "logs.filter.speed.fast": "≥ 50 トークン/秒", + "logs.filter.reset": "フィルターをリセット", + "logs.filter.showingCount": "{total} 件中 {count} 件を表示", + "logs.noMatchingRequests": "一致するリクエストはありません。", "logs.filter.surface.all": "すべて", "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index ab644d34d4..ad0b1ad5c2 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -670,6 +670,26 @@ export const ko: Record = { "logs.noRequests": "아직 요청이 없습니다.", "logs.loadError": "요청 로그를 불러오지 못했습니다.", "logs.filter.surface.label": "표면", + "logs.filter.model.all": "모든 모델", + "logs.filter.provider.label": "공급자", + "logs.filter.provider.all": "모든 공급자", + "logs.filter.status.label": "상태", + "logs.filter.status.all": "모든 상태", + "logs.filter.status.success": "성공 (2xx)", + "logs.filter.status.errors": "오류 (4xx/5xx)", + "logs.filter.time.label": "시간", + "logs.filter.time.all": "전체 시간", + "logs.filter.time.15m": "최근 15분", + "logs.filter.time.1h": "최근 1시간", + "logs.filter.time.24h": "최근 1일", + "logs.filter.speed.label": "속도", + "logs.filter.speed.all": "모든 속도", + "logs.filter.speed.slow": "< 15 토큰/초", + "logs.filter.speed.medium": "15–< 50 토큰/초", + "logs.filter.speed.fast": "≥ 50 토큰/초", + "logs.filter.reset": "필터 초기화", + "logs.filter.showingCount": "{total}개 중 {count}개 표시", + "logs.noMatchingRequests": "일치하는 요청이 없습니다.", "logs.filter.surface.all": "전체", "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index d1e721ebe6..bd1875345a 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -668,6 +668,26 @@ export const ru: Record = { "logs.noRequests": "Запросов пока нет.", "logs.loadError": "Не удалось загрузить журнал запросов.", "logs.filter.surface.label": "Источник", + "logs.filter.model.all": "Все модели", + "logs.filter.provider.label": "Провайдер", + "logs.filter.provider.all": "Все провайдеры", + "logs.filter.status.label": "Статус", + "logs.filter.status.all": "Все статусы", + "logs.filter.status.success": "Успешные (2xx)", + "logs.filter.status.errors": "Ошибки (4xx/5xx)", + "logs.filter.time.label": "Время", + "logs.filter.time.all": "За всё время", + "logs.filter.time.15m": "Последние 15 мин", + "logs.filter.time.1h": "Последний час", + "logs.filter.time.24h": "Последний день", + "logs.filter.speed.label": "Скорость", + "logs.filter.speed.all": "Все скорости", + "logs.filter.speed.slow": "< 15 ток/с", + "logs.filter.speed.medium": "15–< 50 ток/с", + "logs.filter.speed.fast": "≥ 50 ток/с", + "logs.filter.reset": "Сбросить фильтры", + "logs.filter.showingCount": "Показано {count} из {total}", + "logs.noMatchingRequests": "Подходящих запросов нет.", "logs.filter.surface.all": "Все", "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 5a39f0c3c3..1e32b72425 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -675,6 +675,26 @@ export const tr: Record = { "logs.noRequests": "Henüz istek yok.", "logs.loadError": "İstek günlükleri yüklenemedi.", "logs.filter.surface.label": "Yüzey", + "logs.filter.model.all": "Tüm modeller", + "logs.filter.provider.label": "Sağlayıcı", + "logs.filter.provider.all": "Tüm sağlayıcılar", + "logs.filter.status.label": "Durum", + "logs.filter.status.all": "Tüm durumlar", + "logs.filter.status.success": "Başarılı (2xx)", + "logs.filter.status.errors": "Hatalar (4xx/5xx)", + "logs.filter.time.label": "Zaman", + "logs.filter.time.all": "Tüm zamanlar", + "logs.filter.time.15m": "Son 15 dk", + "logs.filter.time.1h": "Son 1 saat", + "logs.filter.time.24h": "Son 1 gün", + "logs.filter.speed.label": "Hız", + "logs.filter.speed.all": "Tüm hızlar", + "logs.filter.speed.slow": "< 15 belirteç/sn", + "logs.filter.speed.medium": "15–< 50 belirteç/sn", + "logs.filter.speed.fast": "≥ 50 belirteç/sn", + "logs.filter.reset": "Filtreleri sıfırla", + "logs.filter.showingCount": "{total} içinden {count} gösteriliyor", + "logs.noMatchingRequests": "Eşleşen istek yok.", "logs.filter.surface.all": "Tümü", "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 94101c189e..db61cb78f2 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -520,6 +520,26 @@ export const zhTW: Record = { "logs.noRequests": "暫無請求。", "logs.loadError": "無法載入請求日誌。", "logs.filter.surface.label": "介面", + "logs.filter.model.all": "所有模型", + "logs.filter.provider.label": "提供者", + "logs.filter.provider.all": "所有提供者", + "logs.filter.status.label": "狀態", + "logs.filter.status.all": "所有狀態", + "logs.filter.status.success": "成功 (2xx)", + "logs.filter.status.errors": "錯誤 (4xx/5xx)", + "logs.filter.time.label": "時間", + "logs.filter.time.all": "所有時間", + "logs.filter.time.15m": "最近 15 分鐘", + "logs.filter.time.1h": "最近 1 小時", + "logs.filter.time.24h": "最近 1 天", + "logs.filter.speed.label": "速度", + "logs.filter.speed.all": "所有速度", + "logs.filter.speed.slow": "< 15 權杖/秒", + "logs.filter.speed.medium": "15–< 50 權杖/秒", + "logs.filter.speed.fast": "≥ 50 權杖/秒", + "logs.filter.reset": "重設篩選", + "logs.filter.showingCount": "顯示 {count}/{total}", + "logs.noMatchingRequests": "沒有相符的請求。", "logs.filter.surface.all": "全部", "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 5626b46abd..8ee2d914df 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -663,6 +663,26 @@ export const zh: Record = { "logs.noRequests": "暂无请求。", "logs.loadError": "无法加载请求日志。", "logs.filter.surface.label": "界面", + "logs.filter.model.all": "所有模型", + "logs.filter.provider.label": "提供商", + "logs.filter.provider.all": "所有提供商", + "logs.filter.status.label": "状态", + "logs.filter.status.all": "所有状态", + "logs.filter.status.success": "成功 (2xx)", + "logs.filter.status.errors": "错误 (4xx/5xx)", + "logs.filter.time.label": "时间", + "logs.filter.time.all": "所有时间", + "logs.filter.time.15m": "最近 15 分钟", + "logs.filter.time.1h": "最近 1 小时", + "logs.filter.time.24h": "最近 1 天", + "logs.filter.speed.label": "速度", + "logs.filter.speed.all": "所有速度", + "logs.filter.speed.slow": "< 15 令牌/秒", + "logs.filter.speed.medium": "15–< 50 令牌/秒", + "logs.filter.speed.fast": "≥ 50 令牌/秒", + "logs.filter.reset": "重置筛选", + "logs.filter.showingCount": "显示 {count}/{total}", + "logs.noMatchingRequests": "没有匹配的请求。", "logs.filter.surface.all": "全部", "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 40b3a265c8..a41b6bdb62 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -1,9 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useI18n, LOCALES, type TFn } from "../i18n/shared"; import { formatProviderDisplayName } from "../provider-icons"; import { formatTokens } from "../format-tokens"; -import { hashLogConversationQuery, matchesLogConversationId } from "../log-conversation-id"; +import { hashLogConversationQuery } from "../log-conversation-id"; import { statusCodeInfo } from "../status-codes"; import { IconX } from "../icons"; import { modelLabel } from "../model-display"; @@ -12,6 +12,8 @@ import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; import { EmptyState, Notice } from "../ui"; import Debug from "./Debug"; +import { LogsFilterBar } from "./logs-filter-bar"; +import { DEFAULT_LOG_FILTER_STATE, extractLogFilterOptions, filterLogs, hasActiveLogFilters, type LogFilterState } from "./logs-filter"; import type { LogsTab } from "./logs-tab-keydown"; import { logsTabKeyDown, readTabFromHash, selectLogsTab } from "./logs-tab-keydown"; @@ -19,9 +21,7 @@ import { modelTitle, type ModelTitleTierOutcome } from "./logs-model-title"; import { speedLabel } from "./logs-speed-label"; import { formatEstimatedUsd, formatEstimatedUsdValue, summarizeEstimatedCosts } from "./logs-cost-format"; import { cacheSplit, isCursorUsageProvider, tokensTitle } from "./logs-token-title"; -import type { LogSurface, LogSurfaceFilter } from "./logs-surface-filter"; -import { logMatchesSurface } from "./logs-surface-filter"; -import { logMatchesModelQuery } from "./logs-model-filter"; +import type { LogSurface } from "./logs-surface-filter"; import { sanitizeLogEntryRouteDecision, validCachedRouteDecision, @@ -31,6 +31,8 @@ function logsCacheKey(apiBase: string): string { return `ocx.logs.list.v1:${apiBase}`; } +const EMPTY_LOGS: LogEntry[] = []; + interface UsageBreakdown { inputTokens: number; outputTokens: number; @@ -373,11 +375,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { { error: null, count: 0 }, ); const [detail, setDetail] = useState(null); - const [surfaceFilter, setSurfaceFilter] = useState("all"); - const [interceptedHelpersOnly, setInterceptedHelpersOnly] = useState(false); - const [conversationFilter, setConversationFilter] = useState(""); - const [modelFilter, setModelFilter] = useState(""); - const [conversationQueryHash, setConversationQueryHash] = useState(); + const [filters, setFilters] = useState(DEFAULT_LOG_FILTER_STATE); const scrollContainerRef = useRef(null); const logRetryRef = useRef<{ key: string; failures: number; nextAttemptAt: number; error: unknown }>( { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }, @@ -472,7 +470,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { }, ); const logsState = logsResource.state; - const logs = logsState.data ?? cachedLogs ?? []; + const logs = logsState.data ?? cachedLogs ?? EMPTY_LOGS; const fetchLogs = logsResource.refresh; const retryLogs = useCallback(() => { logRetryRef.current = { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }; @@ -499,26 +497,23 @@ export default function Logs({ apiBase }: { apiBase: string }) { || (!autoRefresh && settledFailure); const detailInfo = detail ? statusCodeInfo(detail.status, locale) : null; - const conversationQuery = conversationFilter.trim(); + const conversationQuery = filters.conversationId.trim(); useEffect(() => { let cancelled = false; if (!conversationQuery) { - setConversationQueryHash(undefined); + setFilters(prev => prev.conversationQueryHash === undefined ? prev : { ...prev, conversationQueryHash: undefined }); return; } void hashLogConversationQuery(conversationQuery).then(hash => { - if (!cancelled) setConversationQueryHash(hash); + if (!cancelled) setFilters(prev => prev.conversationQueryHash === hash ? prev : { ...prev, conversationQueryHash: hash }); }); return () => { cancelled = true; }; }, [conversationQuery]); - const filteredLogs = logs.filter(log => ( - logMatchesSurface(log, surfaceFilter) - && (!interceptedHelpersOnly || Boolean(log.shadowCallRewrittenFrom)) - && logMatchesModelQuery(log, modelFilter) - && (!conversationQuery || matchesLogConversationId(log.conversationId, conversationQuery, conversationQueryHash)) - )); + const filterOptions = useMemo(() => extractLogFilterOptions(logs), [logs]); + const activeFilters = hasActiveLogFilters(filters); + const filteredLogs = useMemo(() => filterLogs(logs, filters), [logs, filters]); const conversationTotals = conversationQuery ? summarizeFilteredLogs(filteredLogs) : null; // TanStack Virtual returns unstable function identities; React Compiler skips this call. @@ -597,65 +592,16 @@ export default function Logs({ apiBase }: { apiBase: string }) { hidden={tab !== "logs"} > -
- {t("logs.filter.surface.label")} -
- {(["all", "claude", "codex", "grok"] as const).map(surface => ( - - ))} -
- {/* - "Intercepted", not "helper". The marker only exists when Shadow Call Intercept - rewrote the request, so a helper request that was not intercepted looks exactly like - ordinary traffic here. A broader label would promise a classification this data - cannot support. - */} - - - - {conversationQuery && ( - - )} -
+ setFilters(DEFAULT_LOG_FILTER_STATE)} + /> {conversationTotals && (
@@ -714,7 +660,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { {logsState.kind === "failed-cold" ? null : logsState.showSkeleton && logs.length === 0 ? ( ) : filteredLogs.length === 0 ? ( - + 0 && activeFilters ? t("logs.noMatchingRequests") : t("logs.noRequests")} /> ) : ( <>
@@ -864,7 +810,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { t={t} onClose={() => setDetail(null)} onFilterConversation={id => { - setConversationFilter(id); + setFilters(prev => ({ ...prev, conversationId: id })); setDetail(null); }} /> diff --git a/gui/src/pages/logs-filter-bar.tsx b/gui/src/pages/logs-filter-bar.tsx new file mode 100644 index 0000000000..047440f109 --- /dev/null +++ b/gui/src/pages/logs-filter-bar.tsx @@ -0,0 +1,123 @@ +import type { TFn } from "../i18n/shared"; +import { IconX } from "../icons"; +import { formatProviderDisplayName } from "../provider-icons"; +import type { LogFilterState, LogStatusFilter, LogTimeWindow } from "./logs-filter"; + +interface LogsFilterBarProps { + filters: LogFilterState; + options: { models: string[]; providers: string[] }; + hasActiveFilters: boolean; + filteredCount: number; + totalCount: number; + t: TFn; + onFilterChange: (next: LogFilterState) => void; + onResetFilters: () => void; +} + +export function LogsFilterBar({ + filters, options, hasActiveFilters, filteredCount, totalCount, t, onFilterChange, onResetFilters, +}: LogsFilterBarProps) { + const currentSpeed = filters.maxTokPerSec === 15 + ? "slow" + : filters.minTokPerSec === 15 && filters.maxTokPerSec === 50 + ? "medium" + : filters.minTokPerSec === 50 ? "fast" : "all"; + + const handleSpeedChange = (value: string) => { + const bounds = value === "slow" + ? { minTokPerSec: undefined, maxTokPerSec: 15 } + : value === "medium" + ? { minTokPerSec: 15, maxTokPerSec: 50 } + : value === "fast" + ? { minTokPerSec: 50, maxTokPerSec: undefined } + : { minTokPerSec: undefined, maxTokPerSec: undefined }; + onFilterChange({ ...filters, ...bounds }); + }; + + return ( +
+
+ {t("logs.filter.surface.label")} +
+ {(["all", "claude", "codex", "grok"] as const).map(surface => ( + + ))} +
+ + + + + + + + + + + + +
+ +
+ + + {hasActiveFilters && ( +
+ {t("logs.filter.showingCount", { count: filteredCount, total: totalCount })} + +
+ )} +
+
+ ); +} diff --git a/gui/src/styles.css b/gui/src/styles.css index c21b2a16a8..0789ece561 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2155,6 +2155,8 @@ table.logs-table { gap: var(--space-2); margin-bottom: var(--space-3); } +.logs-filter-status { display: inline-flex; align-items: center; gap: var(--space-2); margin-left: auto; } +.logs-toolbar-secondary { margin-top: calc(var(--space-1) * -1); } .logs-segmented { display: inline-flex; diff --git a/gui/tests/logs-auto-refresh.test.tsx b/gui/tests/logs-auto-refresh.test.tsx index b0dbda2501..8038f7550c 100644 --- a/gui/tests/logs-auto-refresh.test.tsx +++ b/gui/tests/logs-auto-refresh.test.tsx @@ -542,8 +542,9 @@ test("Logs: an intercepted helper row is badged and filterable", async () => { await flushMicrotasks(); // The badge names the ORIGINAL helper model, which is the attribution that was being lost. - expect(container.textContent).toContain("I · gpt-5.6-luna"); - expect(container.textContent).toContain("gpt-test"); + const tableText = () => container.querySelector(".logs-table tbody")?.textContent ?? ""; + expect(tableText()).toContain("I · gpt-5.6-luna"); + expect(tableText()).toContain("gpt-test"); const toggle = [...container.querySelectorAll("input[type=checkbox]")].find( input => input.closest("label")?.textContent?.includes("Intercepted helpers only"), @@ -557,8 +558,8 @@ test("Logs: an intercepted helper row is badged and filterable", async () => { }); // Filtered: the marked row stays, the ordinary one goes. - expect(container.textContent).toContain("I · gpt-5.6-luna"); - expect(container.textContent).not.toContain("gpt-test"); + expect(tableText()).toContain("I · gpt-5.6-luna"); + expect(tableText()).not.toContain("gpt-test"); await act(async () => { toggle!.click(); }); await act(async () => { @@ -566,7 +567,7 @@ test("Logs: an intercepted helper row is badged and filterable", async () => { await Promise.resolve(); }); - expect(container.textContent).toContain("gpt-test"); + expect(tableText()).toContain("gpt-test"); await act(async () => { root.unmount(); }); }); diff --git a/gui/tests/logs-filter-bar.test.ts b/gui/tests/logs-filter-bar.test.ts new file mode 100644 index 0000000000..40a6b0027c --- /dev/null +++ b/gui/tests/logs-filter-bar.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { DEFAULT_LOG_FILTER_STATE, type LogFilterState } from "../src/pages/logs-filter"; +import { LogsFilterBar } from "../src/pages/logs-filter-bar"; + +test("Logs mounts the shared rich-filter predicate and filter bar", async () => { + const source = await Bun.file(new URL("../src/pages/Logs.tsx", import.meta.url)).text(); + expect(source).toContain('import { LogsFilterBar } from "./logs-filter-bar";'); + expect(source).toContain("filterLogs(logs, filters)"); + expect(source).toContain("extractLogFilterOptions(logs)"); + expect(source).not.toContain("logMatchesSurface(log, surfaceFilter)"); + expect(source).not.toContain("logMatchesModelQuery(log, modelFilter)"); +}); + +test("LogsFilterBar exposes every engine filter field and reset affordance", async () => { + const source = await Bun.file(new URL("../src/pages/logs-filter-bar.tsx", import.meta.url)).text(); + for (const key of [ + "filters.surface", "filters.interceptedOnly", "filters.provider", "filters.model", + "filters.timeWindow", "filters.minTokPerSec", "filters.maxTokPerSec", "filters.status", + "filters.conversationId", + ]) { + expect(source).toContain(key); + } + expect(source).toContain('t("logs.filter.reset")'); +}); + +test("LogsFilterBar renders labeled controls, count, and reset interaction", async () => { + const win = new Window({ url: "http://localhost/#logs" }); + const previous = { + document: globalThis.document, + window: globalThis.window, + navigator: globalThis.navigator, + actEnvironment: (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT, + }; + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + try { + const container = document.createElement("div"); + document.body.append(container); + const updates: LogFilterState[] = []; + const root = createRoot(container); + const filters = { ...DEFAULT_LOG_FILTER_STATE }; + await act(async () => { + root.render(createElement(LanguageProvider, null, + createElement(LogsFilterBar, { + filters: { ...filters, status: "errors" }, + options: { models: ["gpt-test"], providers: ["openai"] }, + hasActiveFilters: true, + filteredCount: 1, + totalCount: 2, + t: ((key: string, vars?: Record) => key === "logs.filter.showingCount" ? `Showing ${vars?.count} of ${vars?.total}` : key) as never, + onFilterChange: next => updates.push(next), + onResetFilters: () => updates.push(filters), + }), + )); + }); + expect(container.querySelector('select[aria-label="logs.filter.status.label"]')).not.toBeNull(); + expect(container.querySelector('select[aria-label="logs.filter.provider.label"]')).not.toBeNull(); + expect(container.textContent).toContain("Showing 1 of 2"); + const reset = [...container.querySelectorAll("button")].find(button => button.textContent?.includes("logs.filter.reset")); + expect(reset).toBeTruthy(); + await act(async () => { reset!.click(); }); + expect(updates.at(-1)).toEqual(filters); + await act(async () => { root.unmount(); }); + } finally { + Object.defineProperties(globalThis, { + document: { configurable: true, value: previous.document }, + window: { configurable: true, value: previous.window }, + navigator: { configurable: true, value: previous.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previous.actEnvironment; + win.close(); + } +}); From e053045e9a2d49b8d70546223b0d02313d4031fe Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:03:12 -0600 Subject: [PATCH 2/4] fix(gui): address logs filter review findings --- gui/src/i18n/fr.ts | 2 +- gui/src/i18n/tr.ts | 10 ++++----- gui/src/pages/Logs.tsx | 30 +++++++++++++++++++++++++- gui/src/pages/logs-filter-bar.tsx | 4 ++++ gui/src/pages/logs-filter.ts | 5 ++++- gui/src/pages/logs-surface-keydown.ts | 24 +++++++++++++++++++++ gui/tests/logs-filter-bar.test.ts | 31 +++++++++++++++++++++++++++ gui/tests/logs-filter.test.ts | 13 +++++++---- 8 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 gui/src/pages/logs-surface-keydown.ts diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 1f5d60f9ea..38031570cf 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -683,7 +683,7 @@ export const fr: Record = { "logs.filter.speed.medium": "15–< 50 jetons/s", "logs.filter.speed.fast": "≥ 50 jetons/s", "logs.filter.reset": "Réinitialiser les filtres", - "logs.filter.showingCount": "{count} sur {total} affichées", + "logs.filter.showingCount": "Affichage de {count} sur {total}", "logs.noMatchingRequests": "Aucune requête correspondante.", "logs.filter.surface.all": "Toutes", "logs.filter.surface.claude": "Claude", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 1e32b72425..9474b22d46 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -689,9 +689,9 @@ export const tr: Record = { "logs.filter.time.24h": "Son 1 gün", "logs.filter.speed.label": "Hız", "logs.filter.speed.all": "Tüm hızlar", - "logs.filter.speed.slow": "< 15 belirteç/sn", - "logs.filter.speed.medium": "15–< 50 belirteç/sn", - "logs.filter.speed.fast": "≥ 50 belirteç/sn", + "logs.filter.speed.slow": "< 15 token/sn", + "logs.filter.speed.medium": "15–< 50 token/sn", + "logs.filter.speed.fast": "≥ 50 token/sn", "logs.filter.reset": "Filtreleri sıfırla", "logs.filter.showingCount": "{total} içinden {count} gösteriliyor", "logs.noMatchingRequests": "Eşleşen istek yok.", @@ -724,9 +724,9 @@ export const tr: Record = { "logs.col.provider": "Sağlayıcı", "logs.col.status": "Durum", "logs.col.tokens": "Jetonlar", - "logs.col.tokPerSec": "jeton/sn", + "logs.col.tokPerSec": "token/sn", "logs.col.estimatedCost": "~$", - "logs.metric.tokPerSecTitle": "Çıktı jetonu / saniye", + "logs.metric.tokPerSecTitle": "Tam istek süresince saniye başına çıktı tokenı", "logs.metric.estimatedCostTitle": "Tahmini API liste fiyatı", "usage.cost.total": "API liste fiyatı eşdeğeri", "usage.cost.disclaimer": "Fatura makbuzu değildir.", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index a41b6bdb62..19b5f19bc4 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -257,6 +257,10 @@ function formatTokPerSecond(result: TokPerSecondResult | undefined, localeTag?: } const LOGS_POLL_INTERVAL_MS = 2000; +// Relative time filters must advance even when the polled snapshot is unchanged. Keep the +// refresh independent from the network poll so an active 15m/1h/24h window expires rows while +// the proxy is idle. +const LOGS_FILTER_CLOCK_INTERVAL_MS = 30_000; const LOGS_POLL_BACKOFF_MAX_EXPONENT = 4; /** Consecutive failed polls before a stale table is called out. */ const STALE_POLL_FAILURE_LIMIT = 3; @@ -376,6 +380,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { ); const [detail, setDetail] = useState(null); const [filters, setFilters] = useState(DEFAULT_LOG_FILTER_STATE); + const [filterClockNow, setFilterClockNow] = useState(() => Date.now()); const scrollContainerRef = useRef(null); const logRetryRef = useRef<{ key: string; failures: number; nextAttemptAt: number; error: unknown }>( { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }, @@ -499,6 +504,13 @@ export default function Logs({ apiBase }: { apiBase: string }) { const detailInfo = detail ? statusCodeInfo(detail.status, locale) : null; const conversationQuery = filters.conversationId.trim(); + useEffect(() => { + if (filters.timeWindow === "all" || tab !== "logs") return; + setFilterClockNow(Date.now()); + const timer = window.setInterval(() => setFilterClockNow(Date.now()), LOGS_FILTER_CLOCK_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [filters.timeWindow, tab]); + useEffect(() => { let cancelled = false; if (!conversationQuery) { @@ -512,8 +524,24 @@ export default function Logs({ apiBase }: { apiBase: string }) { }, [conversationQuery]); const filterOptions = useMemo(() => extractLogFilterOptions(logs), [logs]); + // A selected option can disappear when the bounded log snapshot rolls over. Native + //