diff --git a/devlog/_plan/260906_d_integrations_delivery/040_logs_filters.md b/devlog/_plan/260906_d_integrations_delivery/040_logs_filters.md index 969ee2a876..574e864bd7 100644 --- a/devlog/_plan/260906_d_integrations_delivery/040_logs_filters.md +++ b/devlog/_plan/260906_d_integrations_delivery/040_logs_filters.md @@ -112,7 +112,7 @@ logs.noMatchingRequests The source range supplies the exact translated values for every key, including both `{count}` and `{total}` placeholders. Retain now-unused `logs.filter.model.placeholder` and conversation-clear keys; deleting them is unrelated churn. -Before B starts, main exchanges the actual B3659 head and changed-key/selector inventory. No B3659 source ref was supplied to this delegate, so no fresh claim of hunk disjointness is made. Both lanes touch all nine catalog files and `styles.css`; merge by keys/selectors, never by choosing one whole file. B preserves `.logs-*`; D preserves `.models-*` and B's shared control fixes. Shared `.select-sm` or global token changes belong to main's integration review. Re-read the final union after either lower stack layer changes; CI must run against that union. Public dashboard docs can also overlap: D inserts the Logs subsection and preserves B's Models wording. +Before B starts, main exchanges the actual B3659 head and changed-key/selector inventory. No B3659 source ref was supplied to this delegate, so no fresh claim of hunk disjointness is made. B confirmed on this run that source #3659 changes nine locale files but no stylesheet, and its implementation has not started. Preserve both lanes by keys; recheck any later B style additions rather than assuming a current stylesheet overlap. B preserves `.logs-*`; D preserves `.models-*` and B's shared control fixes. Shared `.select-sm` or global token changes belong to main's integration review. Re-read the final union after either lower stack layer changes; CI must run against that union. Public dashboard docs can also overlap: D inserts the Logs subsection and preserves B's Models wording. ## Behavioral regression amendments and acceptance diff --git a/devlog/_plan/260906_d_integrations_delivery/041_logs_refresh.md b/devlog/_plan/260906_d_integrations_delivery/041_logs_refresh.md new file mode 100644 index 0000000000..6848309c2c --- /dev/null +++ b/devlog/_plan/260906_d_integrations_delivery/041_logs_refresh.md @@ -0,0 +1,29 @@ +# Logs cycle P refresh + +Current stack parent: Cursor #3707 at6005ea8017dc7d113bba0d8dcef061d4f677c60f, including the parent index repair and current dev. Original #3625 remains4f79746b4cedffeb61700113977cd72adf25c51f; its four SB Yoon-authored mailbox patches are retained in scratch. The first patch dry-run applies on this tree. Apply all four during B, preserving every authored commit, then add only the 040 amendments. + +B coordination confirms3659 implementation has not started; its original scope overlaps nine locales, not styles.css. D owns logs.* keys and scoped.logs-* rules; B will carry both sets when its work starts. No whole-file overwrite of locale dictionaries. + +## Design read and ownership + +Existing dense diagnostic dashboard, existing colors/fonts/native selects and table. Primary workflow is immediate local filtering of already-loaded rows; reset restores defaults. Distinguish an empty ring, no matches, cold network failure, and stale refresh error. No URL-persistence feature, wizard, new icon library or redesign is introduced. Those are outside the adopted original contract. + +Main applies original commits and owns Git/CI/stack, QA fixture and screenshot capture. After A, an inherited implementation worker owns only gui/src/pages/{Logs.tsx,logs-filter-bar.tsx,logs-filter.ts,logs-surface-keydown.ts}, gui/src/styles.css and the four existing/new Logs test files named in040. A separate document worker owns eight web-dashboard guides and structure05. Main preserves original locale commits and resolves any local-key conflicts. + +## Browser verification construction + +Use an ignored .tmp fixture that mounts the real Logs component with the real LanguageProvider and stylesheet. Logs accepts apiBase and consumes only settings/logs for the Logs tab, so a Vite middleware serves canonical synthetic LogEntry arrays and settings under an isolated same-origin /__qa/ path. No real proxy/account data or port10100 is used. Vite performs bundling only; no local test/typecheck/build script is executed. The fixture selects locale/theme and dataset from its own query parameters through normal React/DOM initialization. Browser interaction uses the native in-app browser tooling and actual controls; do not inspect private browser stores. + +Capture component behavior with the source branch at its final UI commit: composition/reset/exact model, no matches/empty, keyboard radio navigation, desktop/mobile containment. Use stable synthetic timestamps away from range boundaries; deterministic timer/rollover behavior remains remote-test evidence. Publish sanitized screenshots under the existing docs-site/public/screenshots convention with immutable commit URLs in the PR. Record head/URL/viewport/locale/theme/scenario for each actual image. + +Remote validation uses project Bun1.4 and explicit Node22.22 in a private macmini checkout: complete suite/typecheck, GUI lint/i18n/build, docs build. Hosted CI remains recorded separately and required at final integration. No local application suites/typecheck. + +## Audit amendment: production layout constraints + +The preview must use the actual stylesheet's `.app` → `.main` → `.main-inner` structure, not mount Logs at full viewport width. Include a `.sidebar` rail occupying the production232px desktop grid column, and the production `.mobile-topbar`/off-canvas sidebar arrangement at the existing breakpoint. The base main-inner max-width980px is overridden to1200px by `.main-inner:has(.logs-page)`; preserve that actual cascade and32px/36px/64px desktop padding, plus22px/18px/48px mobile padding, remain untouched. Render an inert representative navigation rail using existing classes; only Logs functionality is under test. No fixture CSS may widen the main container or shrink these paddings. + +Before each containment capture, inspect rendered `.app`, `.main`, `.main-inner`, toolbar and table-wrapper rectangles at the requested viewport. The toolbar must fit the actual content box; the table's deliberate horizontal scroller is checked separately. This folds audit blocker1 and prevents falsely passing a wide standalone preview. + +## Tooling preparation + +Local GUI dependencies may be installed exactly from the committed lockfile with lifecycle scripts disabled, solely to run the Vite preview. This runs no local application test/typecheck/build script. All suite, lint, typecheck and production-build gates remain remote. diff --git a/devlog/_plan/260906_d_integrations_delivery/042_logs_review_corrections.md b/devlog/_plan/260906_d_integrations_delivery/042_logs_review_corrections.md new file mode 100644 index 0000000000..7568eed82e --- /dev/null +++ b/devlog/_plan/260906_d_integrations_delivery/042_logs_review_corrections.md @@ -0,0 +1,33 @@ +# Logs review corrections: focus and proxy-relative windows + +## Loop specification + +Archetype: bounded correctness repair (C3). Trigger: #3712 review threads PRRT_kwDOS-0Gi86fmmdd and PRRT_kwDOS-0Gi86fmpGe. Goal: keyboard Reset returns focus to a stable selected surface control; relative windows use the proxy clock despite browser wall-clock skew. Non-goals: changing log history, server-side filtering, auth, URLs, or UI layout. Verifier: rendered GUI cases, real management envelope test, pinned remote typecheck/root/GUI checks plus browser activation. Stop: candidate verified; final logs-proof/c-2 still require dev integration and hosted CI. Memory: this042 and ignored receipts. Failed gates stay open. Main reclaims a delegated packet after two distinct worker failures; new scopes require a P amendment. + +Prior D accepted the Cursor guidance candidate92f848e8, with final c-4 shipping still open. These parent Logs findings stopped the admin merge before any mutation. Build the correction on Logs248177c9, preserve the child3715 commit, then merge the corrected parent into that child. Do not rewrite either original author history. Existing screenshots describe the unchanged layout; browser verification adds reset-focus and a deliberately skewed synthetic proxy clock. + +## Grounded owners and changes + +- `src/server/management/logs-usage-routes.ts` GET /api/logs already returns an envelope with timeZone,total,logs (older array support remains in the GUI). Add `generatedAt: Date.now()` in that envelope. It is the proxy epoch milliseconds sampled while preparing the response. No headers/auth/CORS or existing fields change. +- `tests/server/logs-timezone.test.ts`: real handleManagementAPI response must include a finite generatedAt between pre/post request wall-clock samples and still carry logs/total/timeZone. Do not mirror the implementation in a fake formatter. +- `gui/src/pages/Logs.tsx`: capture valid numeric generatedAt on successful logs reads and anchor it to browser performance.now at receipt. The filter clock advances from server epoch plus monotonic elapsed time, not browser Date.now. New samples resynchronize the anchor. An aborted/stale response must not change the clock; clear/replace the anchor when apiBase/resourceKey changes. Network failures keep the last accepted anchor. Legacy arrays/envelopes lacking a usable timestamp preserve the existing browser-clock fallback until a server sample exists; do not pretend old servers supply precision they lack. Poll backoff remains separate. +- Small pure clock logic may live in `gui/src/pages/logs-clock.ts` if that keeps the large page readable; no generic clock framework. Document creation (generatedAt), serialization (envelope), validation/deserialization (finite nonnegative number), and consumers (relative-window timer and immediate selection) in the helper/page. Timezone formatting still uses timeZone independently. +- `gui/src/pages/logs-filter-bar.tsx`: the Reset click handler first performs the existing state reset, then restores focus to the stable All surface radio with a component-owned ref; avoid document-global queries or per-render focus stealing. Pointer and keyboard activation share the handler. The surface control remains mounted and selected after reset; no permanently disabled toolbar or layout expansion. +- `gui/tests/logs-auto-refresh.test.tsx` and `logs-filter-bar.test.ts`: actual rendered Reset activation verifies document.activeElement points to All after reset and the filters/default rows are restored. Add generatedAt envelope fixtures for browser clocks ahead AND behind by hours; a fresh row stays in15m while an older row is excluded. Advance the monotonic clock without network/with auto-refresh OFF to expire the row. A browser wall-clock jump after sampling must not shift the window. Cover fresh sample resync, malformed/legacy fallback, API-base switch and aborted late response not poisoning the active clock. Existing rollover/hash/timer tests remain meaningful, adapting their clock seam where required. A small logs-clock.test.ts is allowed for pure fallback validation; the skew acceptance must exercise rendered Logs. +- `docs-site/src/content/docs/guides/web-dashboard.md`, Korean counterpart and `structure/05_gui-and-management-api.md`: explain proxy-clock windows and stable reset focus, with older-proxy fallback stated accurately. Other translations must not contradict the source; no new untranslated UI strings are needed. + +## Activation matrix + +1. Apply a filter, focus Reset, activate it with keyboard: button can disappear but focus lands on selected All; subsequent navigation continues within the stable surface group. Also click Reset and verify focus. +2. Browser Date.now is six hours ahead, then six hours behind proxy generatedAt. Fifteen-minute window still selects only the same fresh proxy row; all-time remains unaffected. +3. Pause auto refresh, advance monotonic30s tick past a relative cutoff: old row expires with no fetch. Change browser wall-clock separately: rows do not jump. +4. Later successful server sample updates the anchor. A failed or aborted request cannot replace it; a different apiBase cannot inherit the previous server epoch. Legacy/malformed metadata uses documented fallback, never NaN windows. +5. Real management API returns generatedAt in the request time interval and preserves the envelope shape. Existing metrics/authorization tests remain green. + +## Delegation and verification + +Main owns server route/test, docs, Git/CI/FSM and native browser QA. Inherited Huygens owns only the GUI page/filter bar/optional clock helper and the corresponding GUI tests. Nash audits before B; independent implementation review follows. All application test/typecheck/lint/build commands run remotely; no local suite. Remote GUI checks include all GUI tests, lint/i18n/build, plus root typecheck/full suite and docs build. Final receipts state any queued GitHub jobs explicitly rather than marking them passed. + +## Check-phase React Doctor correction + +A cold pinned0.9.11 scan with the actual6005 base available reported two concrete diagnostics: rollover selection was adjusted in a post-render effect, and the rendered filter-bar test assigned an external observer during render. Move reconciliation into acceptance of the latest valid log response, preserving permanent reset when an identity disappears and current spelling when it remains. Keep component rendering pure by observing test state from an effect/event. No rule suppression is planned. Re-run the cold changed-scope scan before the regression suites. The earlier same-head hosted success is retained as an observed run result; it is not evidence that these diagnostics were absent. diff --git a/devlog/_plan/260906_d_integrations_delivery/060_landing.md b/devlog/_plan/260906_d_integrations_delivery/060_landing.md new file mode 100644 index 0000000000..8bac0a0b7f --- /dev/null +++ b/devlog/_plan/260906_d_integrations_delivery/060_landing.md @@ -0,0 +1,17 @@ +# 060 — D integration and final verification + +This operations/verification cycle consumes all five implemented candidates and any explicitly recorded repair work. Main owns it; no new feature scope is implied. + +## Exact action map + +- Read live head/base/reviews/checks for every D carry; compare source-original current heads before closing them. Preserve contributor commits and Co-authored-by trailers. +- Integrate bottom-up with owner-authorized admin merge. Verify fetched origin/dev contains each merge SHA. Retarget every open child immediately; preserve its unique commits and branch identity. A squash requires cascading descendants before further readiness claims. +- Close superseded originals3669/3673/3628/3625 only after actual dev ancestry proof. Resolve3646's alias slice and explicitly preserve its separate thinking/cache request in an exact-scope existing or templated follow-up before closing it; never claim the alias patch fixes cache behavior. +- Reconcile all valid late findings and real CI failures. Any required source repair is planned with exact files/activation before patching; a new independent feature becomes a separate appended cycle, not hidden work here. +- Capture a final integrated dev SHA containing all five fixes and verify actual GitHub CI producers and aggregate on that SHA. Queued/cancelled/skipped application tests are not passes. Record platform dispatch-only limitations honestly. c-2 cannot be met until integrated CI succeeds. +- Recheck shared B locale/model-alias changes and A/C touched seams at final dev. If their source inputs changed the rendered Logs surface, repeat only affected browser scenarios; retain immutable sanitized screenshot URLs. +- Move this completed unit to devlog/_fin only after the published outcome is verified, recording final heads/run URLs/author/source dispositions. Detailed pending security notes remain in ignored scratch; publish only resolved outcomes. + +## Evidence and boundaries + +Use .tmp/d-delivery JSON/log receipts, current GitHub APIs and git merge-base --is-ancestor checks, never old labels or remembered output. Preserve the managed worktree and unrelated edits. No local suites/typecheck, no release/main/preview or dogfood operations. Every push uses --no-verify. Final goal completion requires all original criteria plus late-review and final-CI criteria; this page does not weaken any earlier bar. diff --git a/docs-site/public/screenshots/logs-filters-desktop-en.png b/docs-site/public/screenshots/logs-filters-desktop-en.png new file mode 100644 index 0000000000..004d94d2e1 Binary files /dev/null and b/docs-site/public/screenshots/logs-filters-desktop-en.png differ diff --git a/docs-site/public/screenshots/logs-filters-mobile-ko.png b/docs-site/public/screenshots/logs-filters-mobile-ko.png new file mode 100644 index 0000000000..ba72049f56 Binary files /dev/null and b/docs-site/public/screenshots/logs-filters-mobile-ko.png differ diff --git a/docs-site/public/screenshots/logs-filters-proxy-clock.png b/docs-site/public/screenshots/logs-filters-proxy-clock.png new file mode 100644 index 0000000000..a82351915b Binary files /dev/null and b/docs-site/public/screenshots/logs-filters-proxy-clock.png differ diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md index 9970b26bda..fe1cb199f5 100644 --- a/docs-site/src/content/docs/fr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md @@ -57,6 +57,14 @@ gestionnaire de mots de passe. | **Stockage** | Consultez en lecture seule la répartition du disque de CODEX_HOME — sessions, archives, bases de données et pièces jointes. Pour le nettoyage facultatif des archives, prévisualisez les N % les plus anciennes, puis placez-les en quarantaine dans `CODEX_HOME/.trash` (par défaut) ou supprimez-les définitivement après avoir coché une case explicite. **La stratégie de nettoyage automatique** est facultative et **désactivée par défaut** (`storageCleanupPolicy.enabled`) ; configurez son seuil, sa cible, sa planification et son mode sur la page **Stockage**, ou lancez **Exécuter maintenant**. Les entrées mises en quarantaine peuvent être restaurées depuis cette page (JSONL et fils). Les sessions actives restent en lecture seule. Le nettoyage et la restauration sont refusés tant que Codex verrouille le fichier `state_*.sqlite` le plus récent ou actif. | | **Arrêter** | Arrêtez proprement le proxy et le service d'arrière-plan installé, restaurez Codex natif et quittez (`POST /api/stop`). Sur Windows avec le backend Planificateur de tâches, le tableau de bord refuse et vous demande d'exécuter `ocx stop` : le wrapper peut relancer le proxy après la fin de la tâche, et seul un stop exécuté hors du proxy peut vérifier cette fenêtre de redémarrage avant de restaurer votre configuration client. Rien n'est modifié en cas de refus. | +### Filtrer les requêtes + +Les filtres combinent interface, requêtes interceptées, fournisseur, modèle exact, statut, période, vitesse et identifiant de conversation dans le journal chargé. Les choix incluent les tentatives de repli ; les modèles ignorent la casse et les espaces externes, sans correspondance partielle. Un choix disparu revient à Tous. + +Les périodes de 15 minutes, une heure et un jour évoluent toutes les 30 secondes dans l’onglet Logs, même sans actualisation automatique. La vitesse mesure les jetons de sortie par seconde sur toute la durée : moins de 15, de 15 à moins de 50, ou au moins 50 ; les valeurs indisponibles sont exclues quand ce filtre est actif. Réussite : 2xx ; erreur : 4xx/5xx. + +Le compteur compare les résultats au total chargé ; la réinitialisation restaure toutes les lignes. Aucun résultat diffère d’un journal vide. Flèches et Home/End pilotent le sélecteur d’interface. Aucun historique au-delà du journal chargé n’est interrogé. + ### Liens directs vers une section Il n'existe qu'une seule mise en page, donc aucun commutateur de disposition n'est à configurer. Les sections @@ -176,7 +184,7 @@ L'interface graphique est un client léger de l'API JSON de gestion du proxy. Pa | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | Sélectionner le compte de la prochaine requête et configurer le routage du pool. | | `GET /api/codex-auth/active` · `PUT /api/codex-auth/accounts/priority` | Lire le compte effectif — notamment `pinned` et le compte désigné par `pinnedAccountId` — et définir l'ordre de sélection d'un compte. | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Ajouter un compte au groupe au moyen d’une connexion dans le navigateur. | -| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Lire les métadonnées des requêtes récentes avec des filtres facultatifs de fin de journal, de fournisseur et d'état exact ou par classe. Avec `limit`/`offset`, la pagination remonte depuis la ligne la plus récente (`offset=0` renvoie la dernière page). Forme de la réponse : `{ timeZone, total, logs }`, où `total` est le nombre de lignes filtrées avant pagination. | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Lire les métadonnées des requêtes récentes avec des filtres facultatifs de fin de journal, de fournisseur et d'état exact ou par classe. Avec `limit`/`offset`, la pagination remonte depuis la ligne la plus récente (`offset=0` renvoie la dernière page). Forme de la réponse : `{ timeZone, generatedAt, total, logs }`, où `total` est le nombre de lignes filtrées avant pagination. | | `GET` / `PUT /api/subagent-models` | Lire ou définir les cinq modèles de remplacement `spawn_agent` mis en avant. | | `POST /api/stop` | Arrêter le proxy et le service, restaurer Codex natif et quitter. Refusé avec `respawnable_service` sur le backend Planificateur de tâches Windows, et avec `service_state_unknown` lorsque cet état ne peut pas être lu ; rien n'est modifié dans les deux cas. | diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 67224fbcf2..fcd4a0d682 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -89,6 +89,26 @@ host and port over a LAN IP or an alias. | **Storage** | Read-only CODEX_HOME disk breakdown (sessions, archives, DBs, attachments). Optional archived cleanup: preview the oldest N%, then quarantine to `CODEX_HOME/.trash` (default) or permanently delete behind an explicit checkbox. **Auto-cleanup policy** is opt-in and **default OFF** (`storageCleanupPolicy.enabled`); configure threshold/target/schedule/mode on the Storage page, or trigger **Run now**. Quarantined entries can be restored from the Storage page (JSONL + threads). Active sessions stay read-only. Cleanup and restore are refused while Codex holds the newest/active `state_*.sqlite` locked. | | **Stop** | Gracefully stop the proxy and installed background service, restore native Codex, and exit (`POST /api/stop`). On Windows with the Task Scheduler backend the dashboard refuses and asks you to run `ocx stop` instead: that wrapper can respawn the proxy after the task ends, and only a stop running outside this process can verify the restart window before restoring your client config. Nothing is changed when it refuses. | +### Filtering request logs + +Logs filters combine surface, intercepted requests, provider, exact model, status, time, +speed, and conversation ID over the currently loaded request ring. Provider and model +choices also include fallback attempts; model matching ignores case and surrounding spaces +but does not match partial names. Choices that disappear from the ring reset to All. + +Time windows cover the last 15 minutes, hour, or day and refresh every 30 seconds while the +Logs tab is active, even with auto-refresh off. Windows use the proxy timestamp from +the logs response and advance with elapsed browser time, so a different browser clock +does not shift the cutoff. Older proxies without that timestamp retain the browser-clock +fallback until a valid sample is available. Speed uses output tokens per second over the +full request duration: below 15, 15 to below 50, or at least 50. Unavailable speed values are +excluded when a speed filter is active. Success means 2xx; errors mean 4xx or 5xx. + +Active filters show the matching count out of the loaded total. Reset filters restores all +rows and returns keyboard focus to the All surface control; “No matching requests” +differs from an empty log ring. Use arrow keys or Home/End in +the surface selector. These controls do not query historical records beyond the loaded ring. + ### Linking to a section There is a single layout, so there is no layout switch to configure. Dashboard sections are @@ -270,7 +290,7 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | Select the account for the next request and configure pool routing. | | `GET /api/codex-auth/active` · `PUT /api/codex-auth/accounts/priority` | Read the effective account (including `pinned` and which account is `pinnedAccountId`) and set one account's selection order. | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Add a pool account through browser login. | -| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Read recent request metadata with optional tail, provider, and exact/class status filters. With `limit`/`offset`, paging walks backward from the newest row (`offset=0` returns the latest page). Response shape: `{ timeZone, total, logs }` where `total` is the filtered row count before pagination. | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Read recent request metadata with optional tail, provider, and exact/class status filters. With `limit`/`offset`, paging walks backward from the newest row (`offset=0` returns the latest page). Response shape: `{ timeZone, generatedAt, total, logs }` where `total` is the filtered row count before pagination. | | `GET` / `PUT /api/subagent-models` | Read or set the five featured `spawn_agent` override models. | | `POST /api/stop` | Stop the proxy/service, restore native Codex, and exit. Refused with `respawnable_service` on the Windows Task Scheduler backend, and with `service_state_unknown` when that state cannot be read; nothing is changed either way. | diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 4b7cbd70ad..18e1efdaed 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -48,6 +48,14 @@ bun run dev:gui | **ストレージ** | CODEX_HOME のディスク内訳(セッション、アーカイブ、DB、添付)を読み取り専用で表示。任意のアーカイブクリーンアップ: 最古 N% をプレビューし、既定では `CODEX_HOME/.trash` へ隔離、または明示チェックで完全削除。**自動クリーンアップ方針**はオプトインで**既定 OFF**(`storageCleanupPolicy.enabled`)。Storage ページでしきい値/目標/スケジュール/モードを設定するか **今すぐ実行**。隔離エントリは Storage ページから復元可能(JSONL + スレッド)。アクティブセッションは読み取り専用。最新/アクティブな `state_*.sqlite` がロック中はクリーンアップと復元を拒否。 | | **停止** | プロキシとインストールされたバックグラウンドサービスを正常終了しネイティブ Codex を復元した後終了します(`POST /api/stop`)。ただし Windows のタスク スケジューラ バックエンドではダッシュボードが拒否し、`ocx stop` の実行を促します。タスク終了後もラッパーがプロキシを再起動しうるため、クライアント設定を戻す前にその再起動区間を確認できるのはプロキシの外で動く stop だけです。拒否されたときは何も変更されません。 | +### リクエストログの絞り込み + +Logsではサーフェス、インターセプトされたリクエスト、プロバイダー、完全なモデル名、ステータス、時間、速度、会話IDを組み合わせて、読み込み済みログを絞り込みます。選択肢にはフォールバック試行も含まれます。モデル名は大文字小文字と前後の空白を無視しますが、部分一致ではありません。ログから消えた選択肢は全件に戻ります。 + +時間は直近15分・1時間・1日で、Logsタブでは自動更新をオフにしても30秒ごとに更新します。速度はリクエスト全体の時間あたりの毎秒出力トークン数で、15未満、15以上50未満、50以上です。速度フィルター中は測定不能な行を除外します。成功は2xx、エラーは4xx/5xxです。 + +一致件数と読み込み総数を表示し、リセットで全行を復元します。一致なしと空ログを区別します。サーフェスは矢印キーとHome/Endで操作できます。読み込み範囲外の履歴は検索しません。 + ### セクションへのリンク レイアウトは 1 つだけなので、切り替える設定はありません。代わりに Dashboard の各セクションに URL があります。`#dashboard` は Overview、`#dashboard/providers` と `#dashboard/models` は残りの 2 つです。再読み込み・ブックマーク・戻る操作のいずれでも、表示していたセクションが保たれます。**Logs** も `#logs` と `#logs/debug` で同じように動作します。以前の `#providers/workspace` のブックマークは `#providers` に移動します。 @@ -149,7 +157,7 @@ GUI はプロキシの JSON 管理 API を使うシンクライアントです | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | 次のリクエストで使うアカウントとプールルーティングポリシーを設定します。 | | `GET /api/codex-auth/active` · `PUT /api/codex-auth/accounts/priority` | 実効アカウント(固定中かどうかを示す `pinned` と、固定されているアカウントを示す `pinnedAccountId` を含む)を読み、アカウント 1 件の選択順序を設定します。 | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | ブラウザログインでプールアカウントを追加します。 | -| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | tail、プロバイダー、正確な状態コードまたは状態等級で最近のリクエストメタデータを参照します。`limit`/`offset` は最新行から過去方向にページングします(`offset=0` が最新ページ)。応答は `{ timeZone, total, logs }` で、`total` はページング前の一致件数です。 | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | tail、プロバイダー、正確な状態コードまたは状態等級で最近のリクエストメタデータを参照します。`limit`/`offset` は最新行から過去方向にページングします(`offset=0` が最新ページ)。応答は `{ timeZone, generatedAt, total, logs }` で、`total` はページング前の一致件数です。 | | `GET` / `PUT /api/subagent-models` | `spawn_agent` に優先公開するモデル 5 つを読むか設定します。 | | `POST /api/stop` | プロキシ/サービスを停止しネイティブ Codex を復元した後終了します。Windows タスク スケジューラ バックエンドでは `respawnable_service`、その状態を読み取れない場合は `service_state_unknown` で拒否し、どちらの場合も何も変更されません。 | diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 6f340ddd8b..bc7d86bdf4 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -48,6 +48,27 @@ bun run dev:gui | **Storage** | CODEX_HOME 디스크 사용량(세션, 보관, DB, 첨부)을 읽기 전용으로 표시합니다. 선택적 보관 정리: 가장 오래된 N%를 미리본 뒤 기본으로 `CODEX_HOME/.trash`에 격리하거나, 명시 체크 후 영구 삭제합니다. **자동 정리 정책**은 opt-in이며 **기본 OFF**(`storageCleanupPolicy.enabled`)입니다. Storage 페이지에서 임계값/목표/일정/모드를 설정하거나 **지금 실행**하세요. Storage 페이지에서 격리 항목을 복원할 수 있습니다(JSONL + 스레드). 활성 세션은 읽기 전용입니다. Codex가 최신/활성 `state_*.sqlite`를 잠그면 정리와 복원을 거절합니다. | | **Stop** | 프록시와 설치된 백그라운드 서비스를 정상 종료하고 네이티브 Codex를 복원한 뒤 끝냅니다(`POST /api/stop`). 단, Windows 작업 스케줄러로 관리되는 경우에는 대시보드가 거절하고 `ocx stop`을 안내합니다. 작업이 끝나도 래퍼가 프록시를 다시 띄울 수 있어서, 클라이언트 설정을 되돌리기 전에 그 재시작 구간을 확인할 수 있는 건 프록시 바깥에서 도는 stop뿐입니다. 거절될 때는 아무것도 바뀌지 않습니다. | +### 요청 로그 필터 + +Logs에서는 클라이언트 종류, 가로챈 요청, 공급자, 정확한 모델명, 상태, 시간, +속도, 대화 ID 조건을 함께 적용할 수 있습니다. 현재 불러온 로그만 필터링하며, +공급자·모델 선택지에는 폴백 시도도 포함됩니다. 모델명은 대소문자와 앞뒤 공백을 +무시하지만 부분 이름은 일치하지 않습니다. 선택한 공급자나 모델이 불러온 로그에서 +사라지면 해당 필터만 전체로 돌아갑니다. + +시간 범위는 최근 15분·1시간·1일입니다. Logs 탭에서는 자동 새로고침을 꺼도 +30초마다 시간 필터를 갱신합니다. 프록시가 응답에 담은 시각에 브라우저의 경과 시간을 +더해 계산하므로, 두 기기의 시계가 달라도 범위가 밀리지 않습니다. 시각을 보내지 않는 +이전 프록시에서는 유효한 응답을 받기 전까지 브라우저 시계를 사용합니다. 속도는 전체 요청 시간으로 계산한 초당 출력 토큰 +수이며, 15 미만·15 이상 50 미만·50 이상으로 나뉩니다. 속도 필터를 켜면 측정값이 +없는 요청은 제외됩니다. 성공은 2xx, 오류는 4xx·5xx입니다. + +필터를 적용하면 일치하는 건수와 불러온 전체 건수가 표시됩니다. 필터를 초기화하면 +불러온 모든 행이 다시 나타나고 키보드 포커스는 클라이언트 종류의 전체 선택으로 돌아갑니다. +조건에 맞는 요청이 없는 상태와 로그 자체가 빈 +상태는 구분해서 표시합니다. 클라이언트 종류는 방향키와 Home/End로 선택할 수 있습니다. +불러온 범위 밖의 과거 로그는 조회하지 않습니다. + ### 섹션으로 바로 가기 레이아웃은 하나뿐이라 전환할 설정이 없습니다. 대신 Dashboard의 섹션마다 주소가 있습니다. `#dashboard`는 Overview, `#dashboard/providers`와 `#dashboard/models`는 나머지 두 섹션입니다. 새로고침하거나 북마크해도, 뒤로 가도 보던 섹션이 그대로 유지됩니다. **Logs**도 `#logs`와 `#logs/debug`로 똑같이 동작합니다. 예전 `#providers/workspace` 북마크는 `#providers`로 넘어갑니다. @@ -175,7 +196,7 @@ GUI는 프록시의 JSON 관리 API를 사용하는 얇은 클라이언트입니 | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | 다음 요청에 사용할 계정과 풀 라우팅 정책을 설정합니다. | | `GET /api/codex-auth/active` · `PUT /api/codex-auth/accounts/priority` | 실효 계정(고정 여부를 나타내는 `pinned`와 고정된 계정을 알려주는 `pinnedAccountId` 포함)을 읽고 계정 하나의 선택 순서를 설정합니다. | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | 브라우저 로그인으로 pool 계정을 추가합니다. | -| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | tail, 프로바이더, 정확한 상태 코드 또는 상태 등급으로 최근 요청 메타데이터를 조회합니다. `limit`/`offset`은 최신 행에서 과거 방향으로 페이지네이션합니다(`offset=0`이 최신 페이지). 응답은 `{ timeZone, total, logs }`이며 `total`은 페이지네이션 전 필터 일치 건수입니다. | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | tail, 프로바이더, 정확한 상태 코드 또는 상태 등급으로 최근 요청 메타데이터를 조회합니다. `limit`/`offset`은 최신 행에서 과거 방향으로 페이지네이션합니다(`offset=0`이 최신 페이지). 응답은 `{ timeZone, generatedAt, total, logs }`이며 `total`은 페이지네이션 전 필터 일치 건수입니다. | | `GET` / `PUT /api/subagent-models` | `spawn_agent`에 우선 노출할 모델 5개를 읽거나 설정합니다. | | `POST /api/stop` | 프록시/서비스를 멈추고 네이티브 Codex를 복원한 뒤 종료합니다. Windows 작업 스케줄러 백엔드에서는 `respawnable_service`로, 그 상태를 읽을 수 없으면 `service_state_unknown`으로 거절하며, 두 경우 모두 아무것도 바뀌지 않습니다. | diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 3dce504aa9..81cab7077a 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -48,6 +48,14 @@ bun run dev:gui | **Storage** | Только чтение разбивки диска CODEX_HOME (сессии, архивы, БД, вложения). Опциональная очистка архива: предпросмотр самых старых N%, затем карантин в `CODEX_HOME/.trash` (по умолчанию) или безвозвратное удаление по явному флажку. **Политика автоочистки** — opt-in и **по умолчанию ВЫКЛ** (`storageCleanupPolicy.enabled`); порог/цель/расписание/режим на странице Storage или **Запустить сейчас**. Записи карантина можно восстановить со страницы Storage (JSONL + threads). Активные сессии только для чтения. Очистка и восстановление отклоняются, пока Codex держит блокировку новейшего/активного `state_*.sqlite`. | | **Stop** | Корректная остановка прокси и установленного фонового сервиса, восстановление нативного Codex и выход (`POST /api/stop`). На Windows с бэкендом планировщика заданий дашборд отказывает и просит выполнить `ocx stop`: обёртка может перезапустить прокси после завершения задачи, и проверить это окно перезапуска до восстановления клиентской конфигурации способен только stop, работающий вне прокси. При отказе ничего не изменяется. | +### Фильтрация запросов + +Фильтры объединяют источник, перехваченные запросы, провайдера, точную модель, статус, время, скорость и ID диалога в загруженном журнале. Варианты включают резервные попытки; модель сравнивается без учёта регистра и крайних пробелов, но не по подстроке. Исчезнувший вариант сбрасывается на все записи. + +Периоды 15 минут, час и сутки обновляются каждые 30 секунд на вкладке Logs даже при выключенном автообновлении. Скорость — выходные токены в секунду за полную длительность запроса: меньше 15, от 15 до менее 50, не менее 50; недоступные значения исключаются при активном фильтре скорости. Успех — 2xx, ошибки — 4xx/5xx. + +Счётчик показывает совпадения из загруженного общего числа; сброс возвращает все строки. Нет совпадений и пустой журнал различаются. Источник выбирается стрелками и Home/End. История вне загруженного журнала не запрашивается. + ### Ссылки на разделы Макет теперь один, поэтому переключать нечего. Зато у разделов Dashboard есть собственные адреса: `#dashboard` открывает Overview, а `#dashboard/providers` и `#dashboard/models` — два других раздела. Перезагрузка, закладка и кнопка «Назад» сохраняют выбранный раздел. **Logs** работает так же — `#logs` и `#logs/debug`. Старая закладка `#providers/workspace` теперь ведёт на `#providers`. @@ -159,7 +167,7 @@ GUI — это тонкий клиент поверх JSON-API управлен | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | Выбор аккаунта для следующего запроса и настройка маршрутизации пула. | | `GET /api/codex-auth/active` · `PUT /api/codex-auth/accounts/priority` | Чтение эффективного аккаунта (включая признак закрепления `pinned` и закреплённый аккаунт `pinnedAccountId`) и установка порядка выбора для одного аккаунта. | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Добавление аккаунта пула через вход в браузере. | -| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Чтение метаданных недавних запросов с необязательными фильтрами tail, провайдера и точного/классового статуса. `limit`/`offset` листают назад от самой новой строки (`offset=0` — последняя страница). Ответ: `{ timeZone, total, logs }`, где `total` — число совпадений до пагинации. | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Чтение метаданных недавних запросов с необязательными фильтрами tail, провайдера и точного/классового статуса. `limit`/`offset` листают назад от самой новой строки (`offset=0` — последняя страница). Ответ: `{ timeZone, generatedAt, total, logs }`, где `total` — число совпадений до пагинации. | | `GET` / `PUT /api/subagent-models` | Чтение или настройка пяти выделенных моделей переопределения `spawn_agent`. | | `POST /api/stop` | Остановка прокси/сервиса, восстановление нативного Codex и выход. Отклоняется с `respawnable_service` на бэкенде планировщика заданий Windows и с `service_state_unknown`, когда это состояние не удаётся прочитать; в обоих случаях ничего не изменяется. | diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md index 282c1e4106..e92ad3d387 100644 --- a/docs-site/src/content/docs/tr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md @@ -60,6 +60,14 @@ kararıdır. | **Depolama** | Salt okunur CODEX_HOME disk dökümü (oturumlar, arşivler, DB'ler, ekler). İsteğe bağlı arşivlenmiş temizleme: en eski %N'yi önizleyin, ardından `CODEX_HOME/.trash` konumuna karantinaya alın (varsayılan) veya açık bir onay kutusu arkasında kalıcı olarak silin. **Otomatik temizleme politikası** isteğe bağlıdır ve **varsayılan olarak KAPALIDIR** (`storageCleanupPolicy.enabled`); Depolama sayfasında eşik/hedef/zamanlama/mod yapılandırın veya **Şimdi çalıştır (Run now)**'ı tetikleyin. Karantinaya alınan girdiler Depolama sayfasından geri yüklenebilir (JSONL + iş parçacıkları). Aktif oturumlar salt okunur kalır. Codex en yeni/aktif `state_*.sqlite` dosyasını kilitli tuttuğu sürece temizleme ve geri yükleme reddedilir. | | **Durdur** | Proxy'yi ve kurulu arka plan servisini zarif bir şekilde durdurun, yerel Codex'i geri yükleyin ve çıkın (`POST /api/stop`). Windows'ta Görev Zamanlayıcı arka ucunda panel reddeder ve `ocx stop` çalıştırmanızı ister: görev bittikten sonra sarmalayıcı proxy'yi yeniden başlatabilir ve bu yeniden başlatma penceresini istemci yapılandırmanız geri yüklenmeden önce yalnızca proxy dışında çalışan bir stop doğrulayabilir. Reddedildiğinde hiçbir şey değiştirilmez. | +### İstek günlüklerini filtreleme + +Filtreler yüklü günlükte yüzey, yakalanan istekler, sağlayıcı, tam model adı, durum, zaman, hız ve konuşma kimliğini birleştirir. Seçenekler yedek denemeleri de içerir; model eşleşmesi büyük/küçük harfi ve dış boşlukları yok sayar, kısmi adları eşleştirmez. Kaybolan seçenek tüm kayıtlara döner. + +Son 15 dakika, saat ve gün pencereleri Logs sekmesinde otomatik yenileme kapalıyken de 30 saniyede bir güncellenir. Hız, tam istek süresindeki saniyelik çıktı jetonudur: 15 altı, 15 dahil 50 altı, en az 50; hız filtresi açıkken ölçülemeyenler dışlanır. Başarı 2xx, hata 4xx/5xx anlamındadır. + +Sayaç eşleşen ve yüklü toplam sayıları gösterir; sıfırlama tüm satırları geri getirir. Eşleşme olmaması boş günlükten ayrılır. Yüzey seçimi oklar ve Home/End ile çalışır. Yüklü günlüğün dışındaki geçmiş sorgulanmaz. + ### Bir bölüme bağlantı verme Tek bir düzen vardır, bu nedenle yapılandırılacak bir düzen anahtarı yoktur. @@ -247,7 +255,7 @@ noktalar şunları içerir: | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | Bir sonraki istek için hesabı seçin ve havuz yönlendirmesini yapılandırın. | | `GET /api/codex-auth/active` · `PUT /api/codex-auth/accounts/priority` | Geçerli hesabı okuyun (`pinned` ve hangi hesabın `pinnedAccountId` olduğu dahil) ve bir hesabın seçim sırasını ayarlayın. | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Tarayıcı girişi aracılığıyla bir havuz hesabı ekleyin. | -| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | İsteğe bağlı kuyruk, sağlayıcı ve tam/sınıf durum filtreleriyle son istek meta verilerini okuyun. `limit`/`offset` ile sayfalama en yeni satırdan geriye doğru ilerler (`offset=0` en son sayfayı döndürür). Yanıt şekli: `{ timeZone, total, logs }` burada `total`, sayfalamadan önceki filtrelenmiş satır sayısıdır. | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | İsteğe bağlı kuyruk, sağlayıcı ve tam/sınıf durum filtreleriyle son istek meta verilerini okuyun. `limit`/`offset` ile sayfalama en yeni satırdan geriye doğru ilerler (`offset=0` en son sayfayı döndürür). Yanıt şekli: `{ timeZone, generatedAt, total, logs }` burada `total`, sayfalamadan önceki filtrelenmiş satır sayısıdır. | | `GET` / `PUT /api/subagent-models` | Öne çıkan beş `spawn_agent` geçersiz kılma modelini okuyun veya ayarlayın. | | `POST /api/stop` | Proxy'yi/servisi durdurun, yerel Codex'i geri yükleyin ve çıkın. Windows Görev Zamanlayıcı arka ucunda `respawnable_service`, bu durum okunamadığında `service_state_unknown` ile reddedilir; her iki durumda da hiçbir şey değiştirilmez. | diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 66a9187987..081e6cebb4 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -47,6 +47,14 @@ bun run dev:gui | **Storage** | 只读查看 CODEX_HOME 磁盘占用(会话、归档、数据库、附件)。可选归档清理:预览最旧 N%,默认隔离到 `CODEX_HOME/.trash`,或勾选后永久删除。**自动清理策略**为可选且**默认关闭**(`storageCleanupPolicy.enabled`);可在 Storage 页配置阈值/目标/计划/模式,或点「立即运行」。可在 Storage 页从隔离区恢复(JSONL + 线程)。活动会话保持只读。Codex 锁定最新/活动的 `state_*.sqlite` 时拒绝清理与恢复。 | | **Stop** | 优雅地停止代理和已安装的后台服务,恢复原生 Codex 并退出(`POST /api/stop`)。在使用任务计划程序后端的 Windows 上,仪表板会拒绝并提示改用 `ocx stop`:任务结束后包装器仍可能重新拉起代理,只有运行在代理之外的 stop 才能在恢复客户端配置前确认这个重启窗口。被拒绝时不会做任何更改。 | +### 筛选请求日志 + +Logs 可组合界面、被拦截请求、提供商、完整模型名、状态、时间、速度和会话 ID,筛选当前已加载的日志。选项包含回退尝试;模型匹配忽略大小写及首尾空格,但不做部分匹配。日志中消失的选项恢复为全部。 + +时间范围为最近 15 分钟、1 小时或 1 天;Logs 标签页每 30 秒更新一次,即使关闭自动刷新也会更新。速度按完整请求耗时计算每秒输出 token,分为小于 15、15 至小于 50、至少 50;启用速度筛选时排除无测量值的请求。成功为 2xx,错误为 4xx/5xx。 + +显示匹配数与已加载总数;重置恢复全部行,并区分无匹配与空日志。界面选择支持方向键及 Home/End,不查询已加载范围之外的历史记录。 + ### 链接到某个部分 布局只有一种,无需切换。Dashboard 的各个部分都有自己的地址:`#dashboard` 打开 Overview,`#dashboard/providers` 与 `#dashboard/models` 打开另外两个。刷新、收藏和后退都会保留当前所在的部分。**Logs** 同理,使用 `#logs` 与 `#logs/debug`。旧的 `#providers/workspace` 书签现在会跳转到 `#providers`。 @@ -141,7 +149,7 @@ GUI 是代理 JSON 管理 API 之上的轻量客户端。常用 endpoint 包括 | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | 选择下一次请求使用的账号并配置账号池路由。 | | `GET /api/codex-auth/active` · `PUT /api/codex-auth/accounts/priority` | 读取实际生效的账号(含表示是否固定的 `pinned` 和指明被固定账号的 `pinnedAccountId`),并设置单个账号的选择顺序。 | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | 通过浏览器登录添加池账号。 | -| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | 使用 tail、provider、精确状态码或状态类别筛选近期请求元数据。`limit`/`offset` 从最新一行向前分页(`offset=0` 为最新一页)。响应为 `{ timeZone, total, logs }`,其中 `total` 为分页前的匹配行数。 | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | 使用 tail、provider、精确状态码或状态类别筛选近期请求元数据。`limit`/`offset` 从最新一行向前分页(`offset=0` 为最新一页)。响应为 `{ timeZone, generatedAt, total, logs }`,其中 `total` 为分页前的匹配行数。 | | `GET` / `PUT /api/subagent-models` | 读取或设置五个置顶的 `spawn_agent` override 模型。 | | `POST /api/stop` | 停止代理/服务,恢复原生 Codex 并退出。在 Windows 任务计划程序后端会以 `respawnable_service` 拒绝,无法读取该状态时以 `service_state_unknown` 拒绝;两种情况都不会做任何更改。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md index ccc126a2df..1ead6e4e81 100644 --- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md @@ -51,6 +51,14 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默 | **Usage / Debug** | 檢視 token usage 覆蓋率與趨勢,或啟用可選的 provider transport 和 usage 提取診斷。 | | **Stop** | 優雅地停止代理和已安裝的後臺服務,恢復原生 Codex 並退出(`POST /api/stop`)。在使用工作排程器後端的 Windows 上,儀表板會拒絕並提示改用 `ocx stop`:工作結束後包裝程序仍可能重新啟動 Proxy,只有執行在 Proxy 之外的 stop 才能在還原用戶端設定前確認這個重啟視窗。被拒絕時不會做任何變更。 | +### 篩選請求日誌 + +Logs 可組合介面、被攔截請求、供應商、完整模型名稱、狀態、時間、速度和對話 ID,篩選目前已載入的日誌。選項包含回退嘗試;模型比對忽略大小寫及頭尾空白,但不做部分比對。日誌中消失的選項恢復為全部。 + +時間範圍為最近 15 分鐘、1 小時或 1 天;Logs 分頁每 30 秒更新一次,即使關閉自動重新整理也會更新。速度按完整請求耗時計算每秒輸出 token,分為小於 15、15 至小於 50、至少 50;啟用速度篩選時排除無測量值的請求。成功為 2xx,錯誤為 4xx/5xx。 + +顯示符合數與已載入總數;重設恢復全部列,並區分無符合結果與空日誌。介面選擇支援方向鍵及 Home/End,不查詢已載入範圍以外的歷史記錄。 + ### 連結到某個部分 佈局只有一種,無需切換。Dashboard 的各個部分都有自己的地址:`#dashboard` 開啟 Overview,`#dashboard/providers` 與 `#dashboard/models` 開啟另外兩個。重新整理、收藏和後退都會保留目前所在的部分。**Logs** 同理,使用 `#logs` 與 `#logs/debug`。舊的 `#providers/workspace` 書籤現在會跳轉到 `#providers`。 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index cf04d02f67..b670ccda4c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -665,6 +665,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 5d0c0b0983..a3bff471c0 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -698,6 +698,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 ac0d9f17c3..84b6e3108f 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -679,6 +679,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": "Affichage de {count} sur {total}", + "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 7c6eab5675..6467a9ed4b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -641,6 +641,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 21d9ab45eb..7d5a918439 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -684,6 +684,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 c97b62e9ac..82c3ea4e8a 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -682,6 +682,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 7a6f5107c0..2db94dc0b8 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -689,6 +689,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 jeton/sn", + "logs.filter.speed.medium": "15–< 50 jeton/sn", + "logs.filter.speed.fast": "≥ 50 jeton/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", @@ -720,7 +740,7 @@ export const tr: Record = { "logs.col.tokens": "Jetonlar", "logs.col.tokPerSec": "jeton/sn", "logs.col.estimatedCost": "~$", - "logs.metric.tokPerSecTitle": "Çıktı jetonu / saniye", + "logs.metric.tokPerSecTitle": "Tam istek süresince saniye başına çıktı jetonu", "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/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ed0aeba2d4..f543e948a5 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -534,6 +534,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 44450d8785..c0dbd81a5c 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -677,6 +677,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..2632ae8788 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, useLayoutEffect, 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,9 @@ 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 { logsClockAnchor, logsClockNow, type LogsClockAnchor } from "./logs-clock"; +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 +22,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 +32,8 @@ function logsCacheKey(apiBase: string): string { return `ocx.logs.list.v1:${apiBase}`; } +const EMPTY_LOGS: LogEntry[] = []; + interface UsageBreakdown { inputTokens: number; outputTokens: number; @@ -255,6 +258,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; @@ -373,11 +380,23 @@ 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 [filterClockNow, setFilterClockNow] = useState(() => Date.now()); + const filterClockRef = useRef<{ + key: string; anchor?: LogsClockAnchor; active: boolean; request: number; + }>({ key: resourceKey, active: false, request: 0 }); + // Invalidate the old resource at commit, before passive resource-loader effects. + // A late body read must not mutate this page's clock, cache or retry state. + useLayoutEffect(() => { + const clock = { key: resourceKey, active: true, request: 0 }; + filterClockRef.current = clock; + setFilterClockNow(Date.now()); + return () => { clock.active = false; }; + }, [resourceKey]); + const readFilterClockNow = useCallback(() => { + const clock = filterClockRef.current; + return logsClockNow(clock.key === resourceKey ? clock.anchor : undefined, performance.now(), Date.now()); + }, [resourceKey]); const scrollContainerRef = useRef(null); const logRetryRef = useRef<{ key: string; failures: number; nextAttemptAt: number; error: unknown }>( { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }, @@ -430,6 +449,13 @@ export default function Logs({ apiBase }: { apiBase: string }) { const selectTab = selectLogsTab; const loadLogs = useCallback(async (signal: AbortSignal): Promise => { + const clock = filterClockRef.current; + if (signal.aborted || !clock.active || clock.key !== resourceKey) { + throw signal.reason ?? new DOMException("Obsolete log request", "AbortError"); + } + const request = ++clock.request; + const isCurrent = () => !signal.aborted && clock.active + && filterClockRef.current === clock && clock.request === request; let retry = logRetryRef.current; if (retry.key !== resourceKey) { retry = { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }; @@ -439,14 +465,37 @@ export default function Logs({ apiBase }: { apiBase: string }) { try { const res = await fetch(`${apiBase}/api/logs?limit=2000`, { signal }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim()); - const body = await res.json() as LogEntry[] | { logs?: LogEntry[] }; + const body = await res.json() as LogEntry[] | { logs?: LogEntry[]; generatedAt?: unknown }; + const receivedAt = performance.now(); const raw = Array.isArray(body) ? body : (body.logs ?? []); const next = raw.map(sanitizeLogEntryRouteDecision); + // The resource-store generation guard runs only after this loader returns. + // Guard these local side effects here as fetch/body readers may ignore abort. + if (!isCurrent()) throw signal.reason ?? new DOMException("Obsolete log request", "AbortError"); + // Reconcile when the accepted snapshot changes, using the latest user state + // rather than filters captured when the request started. Persist disappearance + // as All so a later ring cannot resurrect a cleared selection. + const options = extractLogFilterOptions(next); + setFilters(previous => { + const model = previous.model.trim().toLowerCase(); + const provider = previous.provider.trim().toLowerCase(); + const nextModel = model + ? options.models.find(option => option.trim().toLowerCase() === model) ?? "" + : ""; + const nextProvider = provider + ? options.providers.find(option => option.trim().toLowerCase() === provider) ?? "" + : ""; + if (previous.model === nextModel && previous.provider === nextProvider) return previous; + return { ...previous, model: nextModel, provider: nextProvider }; + }); + const sample = logsClockAnchor(Array.isArray(body) ? undefined : body.generatedAt, receivedAt); + if (sample) clock.anchor = sample; + setFilterClockNow(logsClockNow(clock.anchor, receivedAt, Date.now())); logRetryRef.current = { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }; writeSessionListCache(resourceKey, next); return next; } catch (error) { - if (signal.aborted) throw error; + if (!isCurrent()) throw error; const normalized = error ?? new Error("log request failed"); const failures = retry.failures + 1; const backoffMs = LOGS_POLL_INTERVAL_MS * (2 ** Math.min( @@ -472,7 +521,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 +548,30 @@ 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(() => { + if (filters.timeWindow === "all" || tab !== "logs") return; + setFilterClockNow(readFilterClockNow()); + const timer = window.setInterval(() => setFilterClockNow(readFilterClockNow()), LOGS_FILTER_CLOCK_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [filters.timeWindow, tab, readFilterClockNow]); 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, filterClockNow), [logs, filters, filterClockNow]); const conversationTotals = conversationQuery ? summarizeFilteredLogs(filteredLogs) : null; // TanStack Virtual returns unstable function identities; React Compiler skips this call. @@ -597,65 +650,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 +718,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 +868,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-clock.ts b/gui/src/pages/logs-clock.ts new file mode 100644 index 0000000000..e9a8a8bf1f --- /dev/null +++ b/gui/src/pages/logs-clock.ts @@ -0,0 +1,17 @@ +/** Proxy epoch sampled by GET /api/logs, paired with the browser's monotonic receipt time. */ +export interface LogsClockAnchor { + generatedAt: number; + receivedAt: number; +} + +/** Legacy arrays and envelopes without a valid server sample do not replace an anchor. */ +export function logsClockAnchor(generatedAt: unknown, receivedAt: number): LogsClockAnchor | undefined { + return typeof generatedAt === "number" && Number.isFinite(generatedAt) && generatedAt >= 0 + ? { generatedAt, receivedAt } + : undefined; +} + +/** Older proxies retain browser-wall-clock behavior until this resource supplies a sample. */ +export function logsClockNow(anchor: LogsClockAnchor | undefined, monotonicNow: number, wallNow: number): number { + return anchor ? anchor.generatedAt + Math.max(0, monotonicNow - anchor.receivedAt) : wallNow; +} diff --git a/gui/src/pages/logs-filter-bar.tsx b/gui/src/pages/logs-filter-bar.tsx new file mode 100644 index 0000000000..ae478bf61b --- /dev/null +++ b/gui/src/pages/logs-filter-bar.tsx @@ -0,0 +1,134 @@ +import { useRef } from "react"; +import type { TFn } from "../i18n/shared"; +import { IconX } from "../icons"; +import { formatProviderDisplayName } from "../provider-icons"; +import type { LogFilterState, LogStatusFilter, LogTimeWindow } from "./logs-filter"; +import { logsSurfaceKeyDown } from "./logs-surface-keydown"; + +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 allSurfaceRef = useRef(null); + const resetFilters = () => { + onResetFilters(); + allSurfaceRef.current?.focus({ preventScroll: true }); + }; + 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/pages/logs-filter.ts b/gui/src/pages/logs-filter.ts index 45e5dba4ae..d0e97a2caa 100644 --- a/gui/src/pages/logs-filter.ts +++ b/gui/src/pages/logs-filter.ts @@ -116,11 +116,14 @@ export function filterLogs( || log.status > 599)) return false; const logAttempts = attempts(log); + // Model options represent complete identities from the current log snapshot. Match + // selections exactly (case/whitespace-insensitive), while the standalone + // logMatchesModelQuery helper retains its free-text substring semantics. if (modelQuery && ![ normalized(log.model), normalized(log.resolvedModel), ...logAttempts.map(attempt => normalized(attempt.model)), - ].some(value => value?.includes(modelQuery))) return false; + ].some(value => value === modelQuery)) return false; if (providerQuery && ![ normalized(log.provider), diff --git a/gui/src/pages/logs-surface-keydown.ts b/gui/src/pages/logs-surface-keydown.ts new file mode 100644 index 0000000000..010600d2ab --- /dev/null +++ b/gui/src/pages/logs-surface-keydown.ts @@ -0,0 +1,24 @@ +import type { KeyboardEvent } from "react"; +import type { LogSurfaceFilter } from "./logs-surface-filter"; + +const SURFACES: readonly LogSurfaceFilter[] = ["all", "claude", "codex", "grok"]; + +/** Implements arrow/Home/End navigation for the Logs surface radio group. */ +export function logsSurfaceKeyDown( + e: KeyboardEvent, + current: LogSurfaceFilter, + select: (surface: LogSurfaceFilter) => void, +) { + const index = SURFACES.indexOf(current); + let nextIndex = -1; + if (e.key === "ArrowRight" || e.key === "ArrowDown") nextIndex = (index + 1) % SURFACES.length; + else if (e.key === "ArrowLeft" || e.key === "ArrowUp") nextIndex = (index - 1 + SURFACES.length) % SURFACES.length; + else if (e.key === "Home") nextIndex = 0; + else if (e.key === "End") nextIndex = SURFACES.length - 1; + if (nextIndex < 0) return; + + e.preventDefault(); + const next = SURFACES[nextIndex]!; + select(next); + document.getElementById(`logs-surface-${next}`)?.focus(); +} diff --git a/gui/src/styles.css b/gui/src/styles.css index 654d384068..f4895b10cf 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2149,6 +2149,13 @@ 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-filter-container { min-width: 0; } +.logs-filter-container .logs-filter-field { min-width: 0; max-width: 100%; flex-wrap: wrap; } +.logs-filter-container .logs-filter-field .input { min-width: 0; max-width: 100%; } +.logs-filter-container .logs-filter-status { flex-wrap: wrap; max-width: 100%; } +.logs-filter-container .logs-segmented { max-width: 100%; flex-wrap: wrap; } .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..44366f146b 100644 --- a/gui/tests/logs-auto-refresh.test.tsx +++ b/gui/tests/logs-auto-refresh.test.tsx @@ -108,7 +108,7 @@ afterEach(() => { } }); -async function mountLogs(): Promise<{ root: Root; container: HTMLElement }> { +async function mountLogs(apiBase = "http://localhost"): Promise<{ root: Root; container: HTMLElement }> { const { createRoot } = await import("react-dom/client"); const container = document.createElement("div"); document.body.append(container); @@ -117,7 +117,7 @@ async function mountLogs(): Promise<{ root: Root; container: HTMLElement }> { root = createRoot(container); root.render( - + , ); }); @@ -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,708 @@ 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(); }); }); + +// Rich-filter integration uses the same resource/virtualizer harness as the polling tests. +function visibleRequestIds(container: HTMLElement): string[] { + return [...container.querySelectorAll(".logs-table tbody .log-reqid")].map(node => node.textContent ?? ""); +} + +async function changeLogSelect(container: HTMLElement, label: string, value: string): Promise { + const select = container.querySelector(`.logs-filter-container select[aria-label="${label}"]`); + expect(select).not.toBeNull(); + await act(async () => { + select!.value = value; + select!.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + }); + await flushMicrotasks(); +} + +async function enterConversation(container: HTMLElement, value: string): Promise { + const input = container.querySelector('.logs-filter-container input[type="search"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(input, value); + input.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await flushMicrotasks(); + expect(input.value).toBe(value); +} + +function serveLogSnapshot(readRows: () => unknown[], onRequest?: () => void): void { + globalThis.fetch = (async input => { + const url = String(input); + if (url.includes("/api/settings")) return jsonResponse({ timeZone: "UTC" }); + if (!url.includes("/api/logs")) return jsonResponse({}); + onRequest?.(); + return jsonResponse(readRows()); + }) as typeof fetch; +} + +// Happy DOM owns window timers separately. Route only the filter's interval into +// Bun's existing fake clock; leave resource/virtualizer timers on their own paths. +function trackFilterClock() { + const originalSet = window.setInterval.bind(window); + const originalClear = window.clearInterval.bind(window); + const live = new Map>(); + const started: number[] = []; + const cleared: number[] = []; + let nextId = -1; + const setSpy = jest.spyOn(window, "setInterval").mockImplementation((handler, delay, ...args) => { + if (delay !== 30_000 || typeof handler !== "function") return originalSet(handler, delay, ...args); + const id = nextId--; + live.set(id, globalThis.setInterval(() => handler(...args), delay)); + started.push(id); + return id; + }); + const clearSpy = jest.spyOn(window, "clearInterval").mockImplementation(id => { + const timer = id === undefined ? undefined : live.get(id); + if (timer === undefined) return originalClear(id); + globalThis.clearInterval(timer); + live.delete(id!); + cleared.push(id!); + }); + return { + live, started, cleared, + restore() { + for (const timer of live.values()) globalThis.clearInterval(timer); + setSpy.mockRestore(); + clearSpy.mockRestore(); + }, + }; +} + +test("Logs: rich controls intersect rows while options retain the unfiltered ring", async () => { + const matching = { + ...sampleLog, requestId: "match", model: "model-a", status: 500, + shadowCallRewrittenFrom: "helper-model", + }; + const rows = [ + { ...matching, requestId: "other-provider", provider: "xai" }, + { ...matching, requestId: "other-model", model: "model-a-plus" }, + { ...matching, requestId: "other-status", status: 200 }, + { ...matching, requestId: "not-intercepted", shadowCallRewrittenFrom: undefined }, + { ...matching, requestId: "other-surface", surface: "claude" }, + matching, + ]; + serveLogSnapshot(() => rows); + const { root, container } = await mountLogs(); + try { + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual([ + "match", "other-surface", "not-intercepted", "other-status", "other-model", "other-provider", + ]); + await act(async () => { container.querySelector("#logs-surface-codex")!.click(); }); + await changeLogSelect(container, "Provider", "openai"); + await changeLogSelect(container, "Model", "model-a"); + await changeLogSelect(container, "Status", "errors"); + const intercepted = container.querySelector('.logs-filter-container input[type="checkbox"]')!; + await act(async () => { intercepted.click(); }); + expect(visibleRequestIds(container)).toEqual(["match"]); + expect(container.querySelector(".logs-filter-status")?.textContent).toContain("Showing 1 of 6"); + expect([...container.querySelectorAll('select[aria-label="Model"] option')].map(option => option.value)) + .toEqual(["", "model-a", "model-a-plus"]); + expect([...container.querySelectorAll('select[aria-label="Provider"] option')].map(option => option.value)) + .toEqual(["", "openai", "xai"]); + } finally { + await act(async () => { root.unmount(); }); + } +}); + +test("Logs: a relative-time row expires with an unchanged snapshot and auto-refresh off", async () => { + const clock = trackFilterClock(); + let requests = 0; + const rows = [{ ...sampleLog, timestamp: Date.now() - 15 * 60_000 + 1000 }]; + serveLogSnapshot(() => rows, () => requests++); + let mounted: Awaited> | undefined; + try { + mounted = await mountLogs(); + const { container } = mounted; + await flushMicrotasks(); + expect(clock.live.size).toBe(0); + await changeLogSelect(container, "Time", "15m"); + await act(async () => { container.querySelector('.logs-auto-refresh input')!.click(); }); + await flushMicrotasks(); + const pausedRequests = requests; + expect(visibleRequestIds(container)).toEqual(["req-1"]); + expect(clock.live.size).toBe(1); + await act(async () => { jest.advanceTimersByTime(30_000); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual([]); + expect(container.textContent).toContain("No matching requests."); + expect(container.textContent).not.toContain("No requests yet."); + expect(container.querySelector(".logs-filter-status")?.textContent).toContain("Showing 0 of 1"); + expect(requests).toBe(pausedRequests); + } finally { + try { + if (mounted) await act(async () => { mounted!.root.unmount(); }); + } finally { + clock.restore(); + } + } +}); + +test("Logs: relative clock is replaced on window changes and cleared on All, Debug and unmount", async () => { + const clock = trackFilterClock(); + serveLogSnapshot(() => [sampleLog]); + let mounted: Awaited> | undefined; + let unmounted = false; + try { + mounted = await mountLogs(); + const { root, container } = mounted; + await flushMicrotasks(); + expect(clock.started).toEqual([]); + await changeLogSelect(container, "Time", "15m"); + const first = clock.started[0]!; + expect(clock.live.has(first)).toBe(true); + await changeLogSelect(container, "Time", "1h"); + expect(clock.cleared).toEqual([first]); + expect(clock.live.size).toBe(1); + await changeLogSelect(container, "Time", "all"); + expect(clock.live.size).toBe(0); + expect(clock.cleared).toEqual(clock.started); + await changeLogSelect(container, "Time", "24h"); + expect(clock.live.size).toBe(1); + await act(async () => { + container.querySelector("#logs-tab-debug")!.click(); + window.dispatchEvent(new testWindow.Event("hashchange")); + }); + await flushMicrotasks(); + expect(container.querySelector("#logs-tab-debug")?.getAttribute("aria-selected")).toBe("true"); + expect(clock.live.size).toBe(0); + await act(async () => { + container.querySelector("#logs-tab-logs")!.click(); + window.dispatchEvent(new testWindow.Event("hashchange")); + }); + await flushMicrotasks(); + expect(container.querySelector("#logs-tab-logs")?.getAttribute("aria-selected")).toBe("true"); + expect(clock.started).toHaveLength(4); + expect(clock.live.size).toBe(1); + await act(async () => { root.unmount(); }); + unmounted = true; + expect(clock.live.size).toBe(0); + expect(clock.cleared).toEqual(clock.started); + } finally { + try { + if (!unmounted && mounted) await act(async () => { mounted!.root.unmount(); }); + } finally { + clock.restore(); + } + } +}); + +test("Logs: ring rollover clears only vanished model and provider selections", async () => { + let rows = [{ ...sampleLog, model: "model-a", status: 500, conversationId: "conversation-a" }]; + serveLogSnapshot(() => rows); + const { root, container } = await mountLogs(); + try { + await flushMicrotasks(); + await changeLogSelect(container, "Model", "model-a"); + await changeLogSelect(container, "Provider", "openai"); + await changeLogSelect(container, "Status", "errors"); + await changeLogSelect(container, "Time", "1h"); + await enterConversation(container, "conversation-a"); + const select = (label: string) => container.querySelector(`select[aria-label="${label}"]`)!; + rows = [{ ...rows[0]!, model: "model-b" }]; + await advanceSilentRefresh(); + expect(select("Model").value).toBe(""); + expect(select("Provider").value).toBe("openai"); + expect(visibleRequestIds(container)).toEqual(["req-1"]); + await changeLogSelect(container, "Model", "model-b"); + rows = [{ ...rows[0]!, provider: "xai" }]; + await advanceSilentRefresh(); + expect(select("Provider").value).toBe(""); + expect(select("Model").value).toBe("model-b"); + expect(select("Status").value).toBe("errors"); + expect(select("Time").value).toBe("1h"); + expect(container.querySelector('.logs-filter-container input[type="search"]')!.value).toBe("conversation-a"); + expect(visibleRequestIds(container)).toEqual(["req-1"]); + } finally { + await act(async () => { root.unmount(); }); + } +}); + +test("Logs: casing-only rollover retains model and provider selection with current option spellings", async () => { + let rows = [ + { ...sampleLog, requestId: "selected", model: "GPT-5", provider: "OpenAI" }, + { ...sampleLog, requestId: "other-model", model: "model-other", provider: "OpenAI" }, + { ...sampleLog, requestId: "other-provider", model: "GPT-5", provider: "xai" }, + ]; + serveLogSnapshot(() => rows); + const { root, container } = await mountLogs(); + try { + await flushMicrotasks(); + await changeLogSelect(container, "Model", "GPT-5"); + await changeLogSelect(container, "Provider", "OpenAI"); + await changeLogSelect(container, "Status", "success"); + const select = (label: string) => container.querySelector(`select[aria-label="${label}"]`)!; + expect(select("Model").value).toBe("GPT-5"); + expect(select("Provider").value).toBe("OpenAI"); + expect(visibleRequestIds(container)).toEqual(["selected"]); + + rows = rows.map(row => ({ ...row, model: row.model === "GPT-5" ? "gpt-5" : row.model })); + await advanceSilentRefresh(); + expect(select("Model").value).toBe("gpt-5"); + expect(select("Model").selectedOptions[0]?.value).toBe("gpt-5"); + expect(select("Provider").value).toBe("OpenAI"); + expect(visibleRequestIds(container)).toEqual(["selected"]); + + rows = rows.map(row => ({ ...row, provider: row.provider === "OpenAI" ? "openai" : row.provider })); + await advanceSilentRefresh(); + expect(select("Provider").value).toBe("openai"); + expect(select("Provider").selectedOptions[0]?.value).toBe("openai"); + expect(select("Model").value).toBe("gpt-5"); + expect(select("Status").value).toBe("success"); + expect(visibleRequestIds(container)).toEqual(["selected"]); + expect(container.querySelector(".logs-filter-status")?.textContent).toContain("Showing 1 of 3"); + } finally { + await act(async () => { root.unmount(); }); + } +}); + +test("Logs: detail conversation action and reset use the same filter state", async () => { + const digest = jest.spyOn(crypto.subtle, "digest") + .mockResolvedValueOnce(new Uint8Array(32).fill(17).buffer) + .mockResolvedValue(new Uint8Array(32).fill(34).buffer); + serveLogSnapshot(() => [ + { ...sampleLog, requestId: "hashed", conversationId: "11".repeat(16) }, + { ...sampleLog, requestId: "other", conversationId: "22".repeat(16) }, + ]); + let mounted: Awaited> | undefined; + try { + mounted = await mountLogs(); + const { container } = mounted; + await flushMicrotasks(); + await enterConversation(container, "raw-conversation"); + expect(digest).toHaveBeenCalled(); + expect(visibleRequestIds(container)).toEqual(["hashed"]); + await changeLogSelect(container, "Status", "success"); + await changeLogSelect(container, "Time", "1h"); + await act(async () => { container.querySelector(".logs-filter-status button")!.click(); }); + expect(visibleRequestIds(container)).toEqual(["other", "hashed"]); + expect(container.querySelector(".logs-filter-status")).toBeNull(); + expect(document.activeElement).toBe(container.querySelector("#logs-surface-all")); + expect(container.querySelector("#logs-surface-all")?.getAttribute("aria-checked")).toBe("true"); + const detail = container.querySelector('.log-detail-btn[aria-label="Details: other"]')!; + expect(detail).not.toBeNull(); + await act(async () => { detail.click(); }); + const apply = [...container.querySelectorAll("dialog button")] + .find(button => button.textContent?.trim() === "Filter logs"); + expect(apply).toBeDefined(); + await act(async () => { apply!.click(); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual(["other"]); + expect(container.querySelector("dialog")).toBeNull(); + expect(container.querySelector('.logs-filter-container input[type="search"]')!.value).toBe("22".repeat(16)); + } finally { + try { + if (mounted) await act(async () => { mounted!.root.unmount(); }); + } finally { + digest.mockRestore(); + } + } +}); + +test("Logs: a hash completed after reset cannot match a newer conversation query", async () => { + let resolveOld!: (value: ArrayBuffer) => void; + let resolveNew!: (value: ArrayBuffer) => void; + const oldHash = new Promise(resolve => { resolveOld = resolve; }); + const newHash = new Promise(resolve => { resolveNew = resolve; }); + const digest = jest.spyOn(crypto.subtle, "digest").mockReturnValueOnce(oldHash).mockReturnValueOnce(newHash); + serveLogSnapshot(() => [ + { ...sampleLog, requestId: "old", conversationId: "11".repeat(16) }, + { ...sampleLog, requestId: "new", conversationId: "22".repeat(16) }, + ]); + let mounted: Awaited> | undefined; + try { + mounted = await mountLogs(); + const { container } = mounted; + await flushMicrotasks(); + await enterConversation(container, "old-query"); + expect(digest).toHaveBeenCalledTimes(1); + await act(async () => { container.querySelector(".logs-filter-status button")!.click(); }); + expect(visibleRequestIds(container)).toEqual(["new", "old"]); + expect(container.querySelector(".logs-filter-status")).toBeNull(); + await enterConversation(container, "new-query"); + expect(digest).toHaveBeenCalledTimes(2); + await act(async () => { resolveOld(new Uint8Array(32).fill(17).buffer); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual([]); + expect(container.textContent).toContain("No matching requests."); + await act(async () => { resolveNew(new Uint8Array(32).fill(34).buffer); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual(["new"]); + } finally { + try { + if (mounted) await act(async () => { mounted!.root.unmount(); }); + } finally { + digest.mockRestore(); + } + } +}); + +test("Logs: no matches differs from a truly empty ring and reset restores loaded rows", async () => { + let rows = [sampleLog]; + serveLogSnapshot(() => rows); + const { root, container } = await mountLogs(); + try { + await flushMicrotasks(); + await changeLogSelect(container, "Status", "errors"); + expect(container.textContent).toContain("No matching requests."); + expect(container.textContent).not.toContain("No requests yet."); + await act(async () => { container.querySelector(".logs-filter-status button")!.click(); }); + expect(visibleRequestIds(container)).toEqual(["req-1"]); + rows = []; + await advanceSilentRefresh(); + expect(container.textContent).toContain("No requests yet."); + expect(container.textContent).not.toContain("No matching requests."); + await changeLogSelect(container, "Status", "errors"); + expect(container.textContent).toContain("No requests yet."); + expect(container.textContent).not.toContain("No matching requests."); + } finally { + await act(async () => { root.unmount(); }); + } +}); + +test("Logs: a cold empty snapshot shows no requests rather than no matches", async () => { + serveLogSnapshot(() => []); + const { root, container } = await mountLogs(); + try { + await flushMicrotasks(); + expect(container.textContent).toContain("No requests yet."); + expect(container.textContent).not.toContain("No matching requests."); + expect(container.textContent).not.toContain("Could not load request logs."); + expect(container.querySelector(".logs-filter-status")).toBeNull(); + } finally { + await act(async () => { root.unmount(); }); + } +}); + +const PROXY_NOW = 1_800_000_000_000; + +function proxyLogEnvelope(generatedAt: unknown, logs: unknown[]) { + return { generatedAt, timeZone: "UTC", total: logs.length, logs }; +} + +async function renderLogsAt(root: Root, apiBase: string): Promise { + await act(async () => { + root.render(); + }); + await flushMicrotasks(); +} + +test.each([-6, 6])("Logs: proxy clock handles browser skew of %sh, wall jumps and paused expiry", async hours => { + let wallNow = PROXY_NOW + hours * 60 * 60_000; + let monotonic = 1000; + const wall = jest.spyOn(Date, "now").mockImplementation(() => wallNow); + const monotonicClock = jest.spyOn(performance, "now").mockImplementation(() => monotonic); + const clock = trackFilterClock(); + let requests = 0; + const rows = [ + { ...sampleLog, requestId: "too-old", timestamp: PROXY_NOW - 15 * 60_000 - 1000 }, + { ...sampleLog, requestId: "fresh", timestamp: PROXY_NOW - 15 * 60_000 + 1000 }, + ]; + globalThis.fetch = (async input => { + if (!String(input).includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + requests++; + return jsonResponse(proxyLogEnvelope(PROXY_NOW, rows)); + }) as typeof fetch; + let mounted: Awaited> | undefined; + try { + mounted = await mountLogs(); + const { container } = mounted; + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual(["fresh", "too-old"]); + await changeLogSelect(container, "Time", "15m"); + expect(visibleRequestIds(container)).toEqual(["fresh"]); + await act(async () => { container.querySelector(".logs-auto-refresh input")!.click(); }); + await flushMicrotasks(); + const pausedRequests = requests; + wallNow += 12 * 60 * 60_000; + monotonic += 500; + await act(async () => { jest.advanceTimersByTime(30_000); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual(["fresh"]); + wallNow -= 24 * 60 * 60_000; + monotonic += 30_000; + await act(async () => { jest.advanceTimersByTime(30_000); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual([]); + expect(container.textContent).toContain("No matching requests."); + expect(requests).toBe(pausedRequests); + await changeLogSelect(container, "Time", "all"); + expect(visibleRequestIds(container)).toEqual(["fresh", "too-old"]); + } finally { + try { + if (mounted) await act(async () => { mounted!.root.unmount(); }); + } finally { + clock.restore(); + wall.mockRestore(); + monotonicClock.mockRestore(); + } + } +}); + +test("Logs: successful proxy samples resync immediately; legacy, malformed and failed refreshes retain the anchor", async () => { + const wall = jest.spyOn(Date, "now").mockReturnValue(PROXY_NOW - 6 * 60 * 60_000); + let monotonic = 1000; + const monotonicClock = jest.spyOn(performance, "now").mockImplementation(() => monotonic); + const clock = trackFilterClock(); + const rows = [{ ...sampleLog, requestId: "row", timestamp: PROXY_NOW - 5 * 60_000 }]; + let mode: "initial" | "resync" | "legacy" | "malformed" | "failed" = "initial"; + globalThis.fetch = (async input => { + if (!String(input).includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + if (mode === "failed") return jsonResponse({ error: "unavailable" }, 503); + if (mode === "legacy") return jsonResponse(rows); + if (mode === "malformed") return jsonResponse(proxyLogEnvelope(-1, rows)); + return jsonResponse(proxyLogEnvelope(mode === "initial" ? PROXY_NOW : PROXY_NOW + 20 * 60_000, rows)); + }) as typeof fetch; + let mounted: Awaited> | undefined; + try { + mounted = await mountLogs(); + const { container } = mounted; + await flushMicrotasks(); + await changeLogSelect(container, "Time", "15m"); + expect(visibleRequestIds(container)).toEqual(["row"]); + mode = "resync"; + await advanceSilentRefresh(); + // No 30s interval tick yet: receipt of a new server sample updates the filter. + expect(visibleRequestIds(container)).toEqual([]); + for (const next of ["legacy", "malformed", "failed"] as const) { + mode = next; + await advanceSilentRefresh(); + expect(visibleRequestIds(container)).toEqual([]); + } + monotonic += 30_000; + await act(async () => { jest.advanceTimersByTime(30_000); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual([]); + expect(container.textContent).toContain("No matching requests."); + } finally { + try { + if (mounted) await act(async () => { mounted!.root.unmount(); }); + } finally { + clock.restore(); + wall.mockRestore(); + monotonicClock.mockRestore(); + } + } +}); + +test("Logs: switching apiBase clears the old proxy anchor for a legacy envelope", async () => { + const browserNow = PROXY_NOW + 6 * 60 * 60_000; + const wall = jest.spyOn(Date, "now").mockReturnValue(browserNow); + const monotonicClock = jest.spyOn(performance, "now").mockReturnValue(1000); + globalThis.fetch = (async input => { + const url = String(input); + if (!url.includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + if (url.startsWith("http://proxy-a/")) return jsonResponse(proxyLogEnvelope(PROXY_NOW, [ + { ...sampleLog, requestId: "proxy-a", timestamp: PROXY_NOW - 60_000 }, + ])); + return jsonResponse({ logs: [ + { ...sampleLog, requestId: "legacy-old", timestamp: browserNow - 20 * 60_000 }, + { ...sampleLog, requestId: "legacy-fresh", timestamp: browserNow - 5 * 60_000 }, + ] }); + }) as typeof fetch; + let mounted: Awaited> | undefined; + try { + mounted = await mountLogs("http://proxy-a"); + const { root, container } = mounted; + await flushMicrotasks(); + await changeLogSelect(container, "Time", "15m"); + expect(visibleRequestIds(container)).toEqual(["proxy-a"]); + await renderLogsAt(root, "http://proxy-b"); + expect(container.querySelector('select[aria-label="Time"]')!.value).toBe("15m"); + expect(visibleRequestIds(container)).toEqual(["legacy-fresh"]); + } finally { + try { + if (mounted) await act(async () => { mounted!.root.unmount(); }); + } finally { + wall.mockRestore(); + monotonicClock.mockRestore(); + } + } +}); + +// The response headers have arrived, but its body reader deliberately ignores abort. +// This reaches the loader's side-effect boundary after the resource-store guard fired. +function delayedLogBody() { + let resolve!: (body: unknown) => void; + const body = new Promise(done => { resolve = done; }); + const response = jsonResponse({}); + response.json = () => body; + return { response, resolve }; +} + +test("Logs: a late body from an aborted old apiBase cannot poison the new proxy clock", async () => { + const late = delayedLogBody(); + let oldSignal: AbortSignal | undefined; + let oldRequests = 0; + const wall = jest.spyOn(Date, "now").mockReturnValue(PROXY_NOW + 6 * 60 * 60_000); + let monotonic = 1000; + const monotonicClock = jest.spyOn(performance, "now").mockImplementation(() => monotonic); + const clock = trackFilterClock(); + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (!url.includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + if (url.startsWith("http://proxy-a/")) { + oldRequests++; + oldSignal = init?.signal ?? undefined; + return late.response; + } + return jsonResponse(proxyLogEnvelope(PROXY_NOW, [ + { ...sampleLog, requestId: "proxy-b", timestamp: PROXY_NOW - 60_000 }, + ])); + }) as typeof fetch; + let mounted: Awaited> | undefined; + try { + mounted = await mountLogs("http://proxy-a"); + const { root, container } = mounted; + await flushMicrotasks(); + expect(oldRequests).toBe(1); + await renderLogsAt(root, "http://proxy-b"); + expect(oldSignal?.aborted).toBe(true); + await changeLogSelect(container, "Time", "15m"); + await changeLogSelect(container, "Model", "gpt-test"); + await changeLogSelect(container, "Provider", "openai"); + await act(async () => { container.querySelector(".logs-auto-refresh input")!.click(); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual(["proxy-b"]); + await act(async () => { late.resolve(proxyLogEnvelope(PROXY_NOW + 12 * 60 * 60_000, [])); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual(["proxy-b"]); + expect(container.querySelector('select[aria-label="Model"]')!.value).toBe("gpt-test"); + expect(container.querySelector('select[aria-label="Provider"]')!.value).toBe("openai"); + monotonic += 30_000; + await act(async () => { jest.advanceTimersByTime(30_000); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual(["proxy-b"]); + } finally { + try { + if (mounted) await act(async () => { mounted!.root.unmount(); }); + } finally { + clock.restore(); + wall.mockRestore(); + monotonicClock.mockRestore(); + } + } +}); + +test("Logs: aborting an in-flight refresh before pausing cannot replace the accepted clock", async () => { + const late = delayedLogBody(); + let requests = 0; + let lateSignal: AbortSignal | undefined; + const wall = jest.spyOn(Date, "now").mockReturnValue(PROXY_NOW - 6 * 60 * 60_000); + let monotonic = 1000; + const monotonicClock = jest.spyOn(performance, "now").mockImplementation(() => monotonic); + const clock = trackFilterClock(); + globalThis.fetch = (async (input, init) => { + if (!String(input).includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + requests++; + if (requests === 2) { + lateSignal = init?.signal ?? undefined; + return late.response; + } + return jsonResponse(proxyLogEnvelope(PROXY_NOW, [ + { ...sampleLog, requestId: "current", timestamp: PROXY_NOW - 60_000 }, + ])); + }) as typeof fetch; + let mounted: Awaited> | undefined; + try { + mounted = await mountLogs(); + const { container } = mounted; + await flushMicrotasks(); + await changeLogSelect(container, "Time", "15m"); + await advanceSilentRefresh(); + expect(requests).toBe(2); + await act(async () => { container.querySelector(".logs-auto-refresh input")!.click(); }); + await flushMicrotasks(); + expect(lateSignal?.aborted).toBe(true); + const pausedRequests = requests; + await act(async () => { late.resolve(proxyLogEnvelope(PROXY_NOW + 12 * 60 * 60_000, [])); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual(["current"]); + monotonic += 30_000; + await act(async () => { jest.advanceTimersByTime(30_000); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual(["current"]); + expect(requests).toBe(pausedRequests); + } finally { + try { + if (mounted) await act(async () => { mounted!.root.unmount(); }); + } finally { + clock.restore(); + wall.mockRestore(); + monotonicClock.mockRestore(); + } + } +}); + +test("Logs: reappearing options do not resurrect selections cleared by a successful rollover", async () => { + const original = { ...sampleLog, requestId: "original", model: "model-a", provider: "openai" }; + const replacement = { ...sampleLog, requestId: "replacement", model: "model-b", provider: "xai" }; + let rows = [original]; + serveLogSnapshot(() => rows); + const { root, container } = await mountLogs(); + try { + await flushMicrotasks(); + await changeLogSelect(container, "Model", "model-a"); + await changeLogSelect(container, "Provider", "openai"); + await changeLogSelect(container, "Status", "success"); + rows = [replacement]; + await advanceSilentRefresh(); + const select = (label: string) => container.querySelector(`select[aria-label="${label}"]`)!; + expect(select("Model").value).toBe(""); + expect(select("Provider").value).toBe(""); + expect(visibleRequestIds(container)).toEqual(["replacement"]); + rows = [original, replacement]; + await advanceSilentRefresh(); + expect(select("Model").value).toBe(""); + expect(select("Provider").value).toBe(""); + expect(select("Status").value).toBe("success"); + expect(visibleRequestIds(container)).toEqual(["replacement", "original"]); + } finally { + await act(async () => { root.unmount(); }); + } +}); + +test("Logs: a pending refresh reconciles the user's latest selection rather than its starting selection", async () => { + const late = delayedLogBody(); + const original = [ + { ...sampleLog, requestId: "a", model: "model-a", provider: "openai", status: 500 }, + { ...sampleLog, requestId: "b", model: "model-b", provider: "xai", status: 500 }, + ]; + let requests = 0; + globalThis.fetch = (async input => { + if (!String(input).includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + requests++; + return requests === 1 ? jsonResponse(original) : late.response; + }) as typeof fetch; + const { root, container } = await mountLogs(); + try { + await flushMicrotasks(); + await changeLogSelect(container, "Model", "model-a"); + await changeLogSelect(container, "Provider", "openai"); + await advanceSilentRefresh(); + expect(requests).toBe(2); + await changeLogSelect(container, "Model", "model-b"); + await changeLogSelect(container, "Provider", "xai"); + await changeLogSelect(container, "Status", "errors"); + expect(visibleRequestIds(container)).toEqual(["b"]); + await act(async () => { + late.resolve([ + { ...original[0]!, requestId: "other", model: "model-other" }, + { ...original[1]!, requestId: "current", model: "MODEL-B", provider: "XAI" }, + ]); + }); + await flushMicrotasks(); + expect(container.querySelector('select[aria-label="Model"]')!.value).toBe("MODEL-B"); + expect(container.querySelector('select[aria-label="Provider"]')!.value).toBe("XAI"); + expect(container.querySelector('select[aria-label="Status"]')!.value).toBe("errors"); + expect(visibleRequestIds(container)).toEqual(["current"]); + } finally { + await act(async () => { root.unmount(); }); + } +}); diff --git a/gui/tests/logs-clock.test.ts b/gui/tests/logs-clock.test.ts new file mode 100644 index 0000000000..e26bca8612 --- /dev/null +++ b/gui/tests/logs-clock.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import { logsClockAnchor, logsClockNow } from "../src/pages/logs-clock"; + +test.each([undefined, null, "1700000000000", -1, NaN, Infinity, -Infinity])( + "invalid generatedAt %s leaves the browser fallback in effect", generatedAt => { + const anchor = logsClockAnchor(generatedAt, 10); + expect(anchor).toBeUndefined(); + expect(logsClockNow(anchor, 100, 42_000)).toBe(42_000); + }, +); + +test("zero is a valid proxy epoch and elapsed time is monotonic", () => { + const anchor = logsClockAnchor(0, 100); + expect(anchor).toEqual({ generatedAt: 0, receivedAt: 100 }); + expect(logsClockNow(anchor, 150, 80_000)).toBe(50); + expect(logsClockNow(anchor, 160, 1)).toBe(60); +}); + +test("proxy-relative time ignores browser wall-clock skew and subsequent jumps", () => { + const anchor = logsClockAnchor(1_800_000_000_000, 500); + expect(logsClockNow(anchor, 30_500, 1_800_021_600_000)).toBe(1_800_000_030_000); + expect(logsClockNow(anchor, 30_500, 1_799_978_400_000)).toBe(1_800_000_030_000); +}); diff --git a/gui/tests/logs-filter-bar.test.ts b/gui/tests/logs-filter-bar.test.ts new file mode 100644 index 0000000000..06ae773e8e --- /dev/null +++ b/gui/tests/logs-filter-bar.test.ts @@ -0,0 +1,185 @@ +import { expect, jest, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, createElement, useEffect, useState } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import type { TFn } from "../src/i18n/shared"; +import { DEFAULT_LOG_FILTER_STATE, hasActiveLogFilters, type LogFilterState } from "../src/pages/logs-filter"; +import { LogsFilterBar } from "../src/pages/logs-filter-bar"; +import { logsSurfaceKeyDown } from "../src/pages/logs-surface-keydown"; + +test("surface radios support wrapping arrows and Home/End with roving focus", () => { + const previousDocument = globalThis.document; + const win = new Window(); + Object.defineProperty(globalThis, "document", { configurable: true, value: win.document }); + try { + for (const surface of ["all", "claude", "codex", "grok"]) { + const button = win.document.createElement("button"); + button.id = `logs-surface-${surface}`; + win.document.body.append(button); + } + const selected: string[] = []; + const key = (value: string) => ({ key: value, preventDefault() {}, } as never); + logsSurfaceKeyDown(key("ArrowRight"), "grok", surface => selected.push(surface)); + expect(selected).toEqual(["all"]); + expect(win.document.activeElement?.id).toBe("logs-surface-all"); + logsSurfaceKeyDown(key("Home"), "codex", surface => selected.push(surface)); + expect(selected).toEqual(["all", "all"]); + expect(win.document.activeElement?.id).toBe("logs-surface-all"); + logsSurfaceKeyDown(key("Enter"), "all", surface => selected.push(surface)); + expect(selected).toHaveLength(2); + } finally { + Object.defineProperty(globalThis, "document", { configurable: true, value: previousDocument }); + win.close(); + } +}); + +const translate: TFn = (key, vars) => key === "logs.filter.showingCount" + ? `Showing ${vars?.count} of ${vars?.total}` : key; + +async function withFilterBar( + initial: LogFilterState, + exercise: (ui: { container: HTMLElement; win: Window; filters: () => LogFilterState }) => Promise, +): Promise { + const win = new Window({ url: "http://localhost/#logs" }); + const values = { + document: win.document, window: win, navigator: win.navigator, + localStorage: win.localStorage, IS_REACT_ACT_ENVIRONMENT: true, + }; + const previous = Object.fromEntries(Object.keys(values).map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + let root: Root | undefined; + let current = initial; + try { + for (const [key, value] of Object.entries(values)) { + Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + } + const container = document.createElement("div"); + document.body.append(container); + function Harness() { + const [filters, setFilters] = useState(initial); + useEffect(() => { current = filters; }, [filters]); + return createElement(LogsFilterBar, { + filters, options: { models: ["model-a", "model-a-plus"], providers: ["openai", "xai"] }, + hasActiveFilters: hasActiveLogFilters(filters), filteredCount: 1, totalCount: 2, + t: translate, onFilterChange: setFilters, + onResetFilters: () => setFilters({ ...DEFAULT_LOG_FILTER_STATE }), + }); + } + const { createRoot } = await import("react-dom/client"); + root = createRoot(container); + await act(async () => { root!.render(createElement(LanguageProvider, null, createElement(Harness))); }); + await exercise({ container, win, filters: () => current }); + } finally { + try { + if (root) await act(async () => { root!.unmount(); }); + } finally { + win.close(); + for (const [key, descriptor] of Object.entries(previous)) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + } + } +} + +test("LogsFilterBar changes labeled selects without dropping other filters", async () => { + await withFilterBar({ ...DEFAULT_LOG_FILTER_STATE, conversationId: "conversation-a" }, async ui => { + for (const [field, value] of [["provider", "xai"], ["model", "model-a"], ["time", "15m"], ["status", "errors"]] as const) { + const select = ui.container.querySelector(`select[aria-label="logs.filter.${field}.label"]`); + expect(select).not.toBeNull(); + await act(async () => { + select!.value = value; + select!.dispatchEvent(new ui.win.Event("change", { bubbles: true })); + }); + expect(select!.value).toBe(value); + } + const intercepted = ui.container.querySelector('input[type="checkbox"]')!; + await act(async () => { intercepted.click(); }); + expect(ui.filters()).toMatchObject({ + provider: "xai", model: "model-a", timeWindow: "15m", status: "errors", + conversationId: "conversation-a", interceptedOnly: true, + }); + expect(ui.container.querySelector('input[aria-label="logs.filter.conversation.label"]')).not.toBeNull(); + }); +}); + +test("LogsFilterBar speed choices have non-overlapping bounds and clear both bounds on All", async () => { + await withFilterBar({ ...DEFAULT_LOG_FILTER_STATE, provider: "openai" }, async ui => { + const select = ui.container.querySelector('select[aria-label="logs.filter.speed.label"]')!; + for (const [value, min, max] of [ + ["slow", undefined, 15], ["medium", 15, 50], ["fast", 50, undefined], ["all", undefined, undefined], + ] as const) { + await act(async () => { + select.value = value; + select.dispatchEvent(new ui.win.Event("change", { bubbles: true })); + }); + expect(select.value).toBe(value); + expect(ui.filters().minTokPerSec).toBe(min); + expect(ui.filters().maxTokPerSec).toBe(max); + expect(ui.filters().provider).toBe("openai"); + } + }); +}); + +test.each(["pointer", "keyboard"] as const)("LogsFilterBar %s reset restores focus to All and clears every field", async activation => { + await withFilterBar({ + ...DEFAULT_LOG_FILTER_STATE, surface: "grok", status: "errors", provider: "xai", + model: "model-a", timeWindow: "1h", minTokPerSec: 50, interceptedOnly: true, + conversationId: "conversation-a", conversationQueryHash: "cached-hash", + }, async ui => { + expect(ui.container.textContent).toContain("Showing 1 of 2"); + const reset = ui.container.querySelector(".logs-filter-status button")!; + expect(reset).not.toBeNull(); + reset.focus(); + expect(document.activeElement).toBe(reset); + const all = ui.container.querySelector("#logs-surface-all")!; + const focus = jest.spyOn(all, "focus"); + try { + // Native buttons dispatch click with detail=0 for keyboard activation. + // Browser QA separately exercises Enter/Space's native event synthesis. + await act(async () => { + reset.dispatchEvent(new ui.win.MouseEvent("click", { + bubbles: true, cancelable: true, detail: activation === "keyboard" ? 0 : 1, + })); + }); + expect(document.activeElement).toBe(all); + expect(all.getAttribute("aria-checked")).toBe("true"); + expect(all.tabIndex).toBe(0); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); + } finally { + focus.mockRestore(); + } + expect(ui.filters()).toEqual(DEFAULT_LOG_FILTER_STATE); + expect(ui.container.querySelector(".logs-filter-status")).toBeNull(); + expect(ui.container.querySelector('input[type="search"]')!.value).toBe(""); + expect(ui.container.querySelector('input[type="checkbox"]')!.checked).toBe(false); + expect(ui.container.querySelector('select[aria-label="logs.filter.speed.label"]')!.value).toBe("all"); + }); +}); + +test("LogsFilterBar rendered radios move selection, focus and the single tab stop together", async () => { + await withFilterBar({ ...DEFAULT_LOG_FILTER_STATE }, async ui => { + const radio = (surface: string) => ui.container.querySelector(`#logs-surface-${surface}`)!; + radio("all").focus(); + const moves = [ + ["ArrowLeft", "grok"], ["ArrowRight", "all"], ["ArrowDown", "claude"], + ["ArrowUp", "all"], ["End", "grok"], ["Home", "all"], + ] as const; + for (const [key, expected] of moves) { + const event = new ui.win.KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); + await act(async () => { document.activeElement!.dispatchEvent(event); }); + expect(event.defaultPrevented).toBe(true); + expect(ui.filters().surface).toBe(expected); + expect(document.activeElement).toBe(radio(expected)); + expect(radio(expected).getAttribute("aria-checked")).toBe("true"); + const radios = [...ui.container.querySelectorAll('[role="radio"]')]; + expect(radios.filter(button => button.tabIndex === 0)).toEqual([radio(expected)]); + expect(radios.filter(button => button.getAttribute("aria-checked") === "true")).toEqual([radio(expected)]); + } + const unrelated = new ui.win.KeyboardEvent("keydown", { key: "q", bubbles: true, cancelable: true }); + await act(async () => { radio("all").dispatchEvent(unrelated); }); + expect(unrelated.defaultPrevented).toBe(false); + expect(ui.filters().surface).toBe("all"); + expect(document.activeElement).toBe(radio("all")); + }); +}); diff --git a/gui/tests/logs-filter.test.ts b/gui/tests/logs-filter.test.ts index a5cef36d7e..a0f416d6de 100644 --- a/gui/tests/logs-filter.test.ts +++ b/gui/tests/logs-filter.test.ts @@ -46,18 +46,23 @@ describe("rich Logs filtering", () => { expect(filterLogs(logs, DEFAULT_LOG_FILTER_STATE, NOW)).toEqual(logs); }); - test("matches requested, resolved, and attempted models by substring", () => { + test("matches complete requested, resolved, and attempted model identities", () => { const attemptOnly = [{ id: "attempt-only", model: "requested-model", attempts: [{ model: "fallback-only" }], }]; - expect(filterLogs(logs, { ...DEFAULT_LOG_FILTER_STATE, model: "reliable" }, NOW).map(row => row.id)).toEqual(["claude"]); - expect(filterLogs(logs, { ...DEFAULT_LOG_FILTER_STATE, model: "SONNET-4.6" }, NOW).map(row => row.id)).toEqual(["claude"]); - expect(filterLogs(logs, { ...DEFAULT_LOG_FILTER_STATE, model: "terra" }, NOW).map(row => row.id)).toEqual(["codex"]); + expect(filterLogs(logs, { ...DEFAULT_LOG_FILTER_STATE, model: "claude-sonnet-4.6" }, NOW).map(row => row.id)).toEqual(["claude"]); + expect(filterLogs(logs, { ...DEFAULT_LOG_FILTER_STATE, model: "GPT-5.6-TERRA" }, NOW).map(row => row.id)).toEqual(["codex"]); + expect(filterLogs(logs, { ...DEFAULT_LOG_FILTER_STATE, model: "reliable" }, NOW).map(row => row.id)).toEqual([]); expect(filterLogs(attemptOnly, { ...DEFAULT_LOG_FILTER_STATE, model: "fallback-only" }, NOW).map(row => row.id)).toEqual(["attempt-only"]); }); + test("does not treat a stale or partial model selection as a substring query", () => { + expect(filterLogs(logs, { ...DEFAULT_LOG_FILTER_STATE, model: "terra" }, NOW)).toEqual([]); + expect(filterLogs(logs, { ...DEFAULT_LOG_FILTER_STATE, model: "gpt-5.6-terra-old" }, NOW)).toEqual([]); + }); + test("matches the selected provider on the row or any attempt", () => { expect(filterLogs(logs, { ...DEFAULT_LOG_FILTER_STATE, provider: "OPENAI" }, NOW).map(row => row.id)).toEqual(["codex"]); expect(filterLogs(logs, { ...DEFAULT_LOG_FILTER_STATE, provider: "anthropic" }, NOW).map(row => row.id)).toEqual(["claude"]); @@ -139,3 +144,38 @@ describe("rich Logs filtering", () => { expect(hasActiveLogFilters({ ...DEFAULT_LOG_FILTER_STATE, conversationId: " conv " })).toBe(true); }); }); + +test.each([ + ["15m", 15 * 60_000], ["1h", 60 * 60_000], ["24h", 24 * 60 * 60_000], +] as const)("relative window %s includes its lower boundary and expires it as time advances", (timeWindow, duration) => { + const rows = [ + { id: "before", timestamp: NOW - duration - 1 }, + { id: "boundary", timestamp: NOW - duration }, + { id: "inside", timestamp: NOW - duration + 1 }, + { id: "invalid", timestamp: Number.NaN }, + ]; + const filters = { ...DEFAULT_LOG_FILTER_STATE, timeWindow }; + expect(filterLogs(rows, filters, NOW).map(row => row.id)).toEqual(["boundary", "inside"]); + expect(filterLogs(rows, filters, NOW + 1).map(row => row.id)).toEqual(["inside"]); +}); + +test("speed buckets separate values immediately below and at both boundaries", () => { + const rows = [14.99, 15, 49.99, 50].map(value => ({ + id: String(value), displayMetrics: { tokPerSecond: { kind: "value" as const, value } }, + })); + expect(filterLogs(rows, { ...DEFAULT_LOG_FILTER_STATE, maxTokPerSec: 15 }).map(row => row.id)).toEqual(["14.99"]); + expect(filterLogs(rows, { ...DEFAULT_LOG_FILTER_STATE, minTokPerSec: 15, maxTokPerSec: 50 }).map(row => row.id)) + .toEqual(["15", "49.99"]); + expect(filterLogs(rows, { ...DEFAULT_LOG_FILTER_STATE, minTokPerSec: 50 }).map(row => row.id)).toEqual(["50"]); +}); + +test("exact model choices distinguish prefix siblings and compose with a provider on another attempt", () => { + const rows = [ + { id: "exact", model: "model-a", provider: "openai" }, + { id: "sibling", model: "model-a-plus", provider: "openai" }, + { id: "resolved", model: "requested", resolvedModel: " MODEL-A ", provider: "openai" }, + { id: "attempt", attempts: [{ model: "model-a", provider: "first" }, { model: "other", provider: "openai" }] }, + ]; + expect(filterLogs(rows, { ...DEFAULT_LOG_FILTER_STATE, model: "model-a", provider: "openai" }).map(row => row.id)) + .toEqual(["exact", "resolved", "attempt"]); +}); diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 5d909283f2..dfb3c74df0 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -111,6 +111,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise { @@ -24,16 +24,23 @@ describe("log timestamp timezone (#725)", () => { expect(() => new Intl.DateTimeFormat("en-US", { timeZone: body.timeZone as string })).not.toThrow(); }); - test("/api/logs envelope includes a usable timeZone", async () => { + test("/api/logs envelope includes the proxy clock and a usable timeZone", async () => { const url = new URL("http://localhost/api/logs"); + const before = Date.now(); const response = await handleManagementAPI(new Request(url), url, config); + const after = Date.now(); expect(response?.status).toBe(200); const body = await response!.json() as { timeZone?: unknown; + generatedAt?: unknown; total?: unknown; logs?: unknown; }; expect(typeof body.timeZone).toBe("string"); + expect(typeof body.generatedAt).toBe("number"); + expect(Number.isFinite(body.generatedAt)).toBe(true); + expect(body.generatedAt as number).toBeGreaterThanOrEqual(before); + expect(body.generatedAt as number).toBeLessThanOrEqual(after); expect(typeof body.total).toBe("number"); expect(Array.isArray(body.logs)).toBe(true); expect(() => new Intl.DateTimeFormat("en-US", { timeZone: body.timeZone as string })).not.toThrow();