Merge pull request #1152 from kaitranntt/kai/feat/1138-logging-revamp
feat(logs): structured logging contract and dashboard logs page redesign (#1138)
@@ -0,0 +1,218 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Logs UI Redesign - Evidence (#1142, parent #1138)</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #ffffff;
|
||||
--fg: #18181b;
|
||||
--muted: #71717a;
|
||||
--border: #e4e4e7;
|
||||
--accent: #2563eb;
|
||||
--callout: #ef4444;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
line-height: 1.5;
|
||||
}
|
||||
header.page { max-width: 1180px; margin: 0 auto; padding: 48px 32px 24px; }
|
||||
header.page h1 { font-size: 28px; margin: 0 0 8px; letter-spacing: -0.01em; }
|
||||
header.page p { margin: 4px 0; color: var(--muted); font-size: 14px; }
|
||||
main { max-width: 1180px; margin: 0 auto; padding: 0 32px 64px; }
|
||||
section.surface {
|
||||
margin: 32px 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #fafafa;
|
||||
}
|
||||
section.surface > header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--border); background: #ffffff;
|
||||
}
|
||||
section.surface > header h2 { margin: 0; font-size: 16px; }
|
||||
section.surface > header .meta { font-size: 12px; color: var(--muted); }
|
||||
.body { padding: 20px; background: #ffffff; }
|
||||
figure { margin: 0; }
|
||||
figure img { width: 100%; height: auto; display: block; border-radius: 8px; border: 1px solid var(--border); }
|
||||
figure figcaption { font-size: 13px; color: var(--muted); margin-top: 10px; }
|
||||
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.mobile-row { display: grid; grid-template-columns: 1fr 380px; gap: 24px; align-items: start; }
|
||||
ul.checklist { margin: 16px 0; padding-left: 20px; font-size: 14px; }
|
||||
ul.checklist li { margin: 6px 0; }
|
||||
code { background: #f4f4f5; padding: 1px 6px; border-radius: 4px; font-size: 90%; }
|
||||
.badge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
||||
background: #f4f4f5; color: #52525b; font-size: 11px;
|
||||
font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.legend { background: #fffbeb; border: 1px solid #fde68a; padding: 12px 16px; border-radius: 8px; font-size: 13px; color: #713f12; margin: 12px 0 0; }
|
||||
hr { border: 0; border-top: 1px solid var(--border); margin: 32px 0; }
|
||||
.meta-list { list-style: none; padding: 0; margin: 0; font-size: 13px; color: var(--muted); }
|
||||
.meta-list li { margin: 4px 0; }
|
||||
@media (max-width: 900px) {
|
||||
.pair, .mobile-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="page">
|
||||
<span class="badge">Force-UI evidence . light theme . targeted mode</span>
|
||||
<h1>Dashboard logs page redesign</h1>
|
||||
<p>Sub-issue <a href="https://github.com/kaitranntt/ccs/issues/1142">#1142</a> .
|
||||
parent <a href="https://github.com/kaitranntt/ccs/issues/1138">#1138</a> .
|
||||
sibling backend <a href="https://github.com/kaitranntt/ccs/issues/1141">#1141</a></p>
|
||||
<p>Branch <code>kai/feat/1138-logs-ui-redesign</code> .
|
||||
target <code>kai/feat/1138-logging-revamp</code> (integration) .
|
||||
captures via <code>?mock=logs</code> against local dashboard at 1440x900 (mobile at 390x844)</p>
|
||||
<p class="legend">
|
||||
Targeted mode chosen: original "before" UI is captured in
|
||||
<a href="https://github.com/kaitranntt/ccs/issues/1138#issuecomment-4351038690">issue #1138 comment</a>
|
||||
(contributor screenshot labeled "looks terrible"). This evidence shows the redesigned
|
||||
state with red callout boxes annotating new behaviors.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="surface">
|
||||
<header>
|
||||
<h2>What changed</h2>
|
||||
<span class="meta">redesign-existing-projects . web-design-guidelines</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<ul class="checklist">
|
||||
<li>Removed ornamental glow / radial gradients / faux-HUD copy from <code>logs.tsx</code> and <code>components/logs/*</code>.</li>
|
||||
<li>Calm 3-pane shell on desktop, stacked + bottom-sheet on mobile.</li>
|
||||
<li>List virtualized via <code>react-virtuoso</code>; trace-grouped rows by <code>requestId</code>.</li>
|
||||
<li>Filters split into primary (search/level/source) + advanced (module/stage/requestId/time window).</li>
|
||||
<li>Live-tail pause/resume with rotation-safe id-set diff for new-entries pill.</li>
|
||||
<li>Detail panel: Overview / Context / Raw tabs; copy JSON, copy requestId, "Show trace" jump.</li>
|
||||
<li>Designed states: empty (selection / no data / no results / out-of-scope) and error w/ retry.</li>
|
||||
<li>Keyboard: <code>j</code>/<code>k</code> . <code>Enter</code> . <code>Esc</code> . <code>/</code> . <code>Space</code> . <code>?</code> + visible <code>?</code> button in header.</li>
|
||||
<li>Reduced-motion respected; AA contrast tokens; visible focus rings.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface">
|
||||
<header>
|
||||
<h2>1. Overview - default state</h2>
|
||||
<span class="meta">1440x900 . light . callouts on Live pill (1) and ? button (2)</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<figure>
|
||||
<img src="screenshots/01-overview.png" alt="Logs page default state" />
|
||||
<figcaption>List + filters + detail panel populated. <strong>(1)</strong> connection status pill ("Live"). <strong>(2)</strong> new <code>?</code> button surfaces keyboard shortcuts dialog.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface">
|
||||
<header>
|
||||
<h2>2. Trace grouping</h2>
|
||||
<span class="meta">collapse-by-requestId . expanded vs collapsed states</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<figure>
|
||||
<img src="screenshots/02-trace-grouping.png" alt="Trace grouping with expanded and collapsed states" />
|
||||
<figcaption><strong>(1)</strong> expanded trace shows 5 indented stage rows (intake / auth / route / handler / provider) under the parent row, all sharing <code>req_0001</code>. <strong>(2)</strong> collapsed trace badge ("trace . 6 stages") with chevron - one click toggles.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface">
|
||||
<header>
|
||||
<h2>3. Live-tail paused</h2>
|
||||
<span class="meta">pause/resume affordance</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<figure>
|
||||
<img src="screenshots/03-live-tail-paused.png" alt="Live tail paused state" />
|
||||
<figcaption><strong>(1)</strong> sticky header swaps to "Resume tail" with play icon when paused (was "Pause tail" with pause icon). <strong>(2)</strong> connection pill stays "Live" - polling continues to count new entries; UI just stops auto-scrolling.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface">
|
||||
<header>
|
||||
<h2>4. Advanced filters</h2>
|
||||
<span class="meta">primary + advanced split</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<figure>
|
||||
<img src="screenshots/04-advanced-filters.png" alt="Advanced filters expanded" />
|
||||
<figcaption>Advanced filters expand inline below primary filters: <code>module</code> . <code>stage</code> . <code>requestId</code> . <code>time window</code>. Disclosure chevron flips on toggle. All inputs debounced 250ms; in-flight queries cancelled on filter change.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface">
|
||||
<header>
|
||||
<h2>5. Empty state - filtered to nothing</h2>
|
||||
<span class="meta">designed empty + active filter chip</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<figure>
|
||||
<img src="screenshots/05-empty-state.png" alt="Empty state with active filter chip" />
|
||||
<figcaption>Filter <code>requestId: req_99999</code> matches nothing. Center pane shows centered icon + "No entries match your filters" + actionable copy. Active filter chip <code>requestId: req_99999</code> with x to clear; "Clear all" link below. Right pane shows distinct "No entry selected" empty variant.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface">
|
||||
<header>
|
||||
<h2>6. Keyboard shortcuts dialog</h2>
|
||||
<span class="meta">discoverable via ? button or ? keypress</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<figure>
|
||||
<img src="screenshots/06-shortcuts-dialog.png" alt="Keyboard shortcuts dialog" />
|
||||
<figcaption><strong>(1)</strong> new <code>?</code> button in header. <strong>(2)</strong> dialog lists all 7 keybindings: <code>j</code>/<code>arrow-down</code> next, <code>k</code>/<code>arrow-up</code> prev, <code>Enter</code> focus detail, <code>Esc</code> close mobile, <code>/</code> search, <code>Space</code> pause/resume, <code>?</code> help. Background shows the upstream-errors view with multiple error traces visible.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface">
|
||||
<header>
|
||||
<h2>7. Mobile - 390x844</h2>
|
||||
<span class="meta">stacked layout, no detail-pane fight</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<div class="mobile-row">
|
||||
<div></div>
|
||||
<figure>
|
||||
<img src="screenshots/07-mobile.png" alt="Mobile layout 390x844" />
|
||||
<figcaption>Header collapses to icon row (Live pill, Refresh, ?, Settings). Filters stack vertically. Detail panel becomes bottom sheet on row tap (not shown here - empty state at small viewport just shows the list).</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<section class="surface">
|
||||
<header>
|
||||
<h2>Capture metadata</h2>
|
||||
</header>
|
||||
<div class="body">
|
||||
<ul class="meta-list">
|
||||
<li>Theme: light (default per force-UI rule).</li>
|
||||
<li>Backend: mock fixture via <code>?mock=logs</code> URL flag (150-entry deterministic fixture incl. 5 traces of 3-6 stages each).</li>
|
||||
<li>User / dataset / fixture / theme / viewport held constant within each capture.</li>
|
||||
<li>Callouts: DOM overlay injected via Playwright <code>browser_evaluate</code>, red <code>#ef4444</code> 3px solid border, 8px expanded padding (no ImageMagick).</li>
|
||||
<li>Capture tool: Playwright MCP. Annotations baked into the PNG by injecting overlay divs before screenshot.</li>
|
||||
<li>HEAD SHA: <code>1c814787a671be1121154b1c72f48932ec4f9904</code> - orchestrator pins after final commit.</li>
|
||||
<li>PR number: <code>1146</code> - orchestrator renames asset directory after PR creation.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -417,7 +417,7 @@ Windows fallback: Copies if symlinks unavailable
|
||||
- Native JSON only, no external dependencies
|
||||
|
||||
### TypeScript (src/*.ts)
|
||||
- Node.js 14+, Bun 1.0+, TypeScript 5.3, strict mode
|
||||
- Node.js 18+, Bun 1.0+, TypeScript 5.3, strict mode
|
||||
- `child_process.spawn`, handle SIGINT/SIGTERM
|
||||
|
||||
### Terminal Output
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# Logging Contract
|
||||
|
||||
Single source of truth for structured backend logging in CCS CLI. Companion to GitHub issues #1138 (umbrella) and #1141 (backend instrumentation).
|
||||
|
||||
## Overview
|
||||
|
||||
CCS emits structured JSONL log entries for backend behavior (proxy daemons, OAuth flows, target spawn lifecycle, executor errors, etc.). This document defines the canonical schema, request-correlation pattern, lifecycle stages, and redaction policy.
|
||||
|
||||
> CLI text output (`ok / info / warn / fail` from `src/utils/ui.ts`) is **NOT** affected by this contract. Logs are a separate channel — never printed to stdout/stderr.
|
||||
|
||||
## Schema (`LogEntry`)
|
||||
|
||||
Defined in `src/services/logging/log-types.ts`.
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `id` | `string` | yes | UUID per entry. |
|
||||
| `timestamp` | `string` | yes | ISO 8601. |
|
||||
| `level` | `'error'\|'warn'\|'info'\|'debug'` | yes | |
|
||||
| `source` | `string` | yes | Module-scoped identifier (e.g. `proxy:openai-compat:messages`). |
|
||||
| `event` | `string` | yes | Dotted machine-readable event name (e.g. `request.received`). |
|
||||
| `message` | `string` | yes | Human-readable summary. |
|
||||
| `processId` | `number` | yes | `process.pid`. |
|
||||
| `runId` | `string` | yes | Stable per-process id. |
|
||||
| `context` | `object` | no | Free-form structured fields (redacted). |
|
||||
| `requestId` | `string` | no | Correlates entries belonging to one inbound request across stages. |
|
||||
| `stage` | `LogStage` | no | Lifecycle stage tag. |
|
||||
| `latencyMs` | `number` | no | Elapsed ms (typically on `respond` / `cleanup`). |
|
||||
| `error` | `{name, message, code?, stack?}` | no | Structured error metadata; never raw token strings. |
|
||||
|
||||
Old free-form entries (no `requestId` / `stage`) are still valid; new fields are additive.
|
||||
|
||||
### Example
|
||||
|
||||
```jsonl
|
||||
{"id":"...","timestamp":"2026-04-30T12:34:56.000Z","level":"info","source":"proxy:openai-compat:messages","event":"request.received","message":"Proxy /v1/messages request received","processId":42,"runId":"r1","requestId":"a1b2...","stage":"intake","context":{"method":"POST"}}
|
||||
```
|
||||
|
||||
## Lifecycle Stages
|
||||
|
||||
`LogStage` is one of:
|
||||
|
||||
| Stage | When to emit |
|
||||
|-------|--------------|
|
||||
| `intake` | Inbound request received at an entry edge (HTTP handler, CLI dispatch). |
|
||||
| `route` | Destination/profile/target resolution. |
|
||||
| `auth` | Authentication / authorization (token exchange, profile auth). |
|
||||
| `dispatch` | Outbound request prepared / child process spawned. |
|
||||
| `upstream` | Upstream call in flight (provider HTTP / spawned child running). |
|
||||
| `transform` | Payload translation (request/response shape conversion). |
|
||||
| `respond` | Response written / dispatched (`latencyMs` typically populated). |
|
||||
| `cleanup` | Error path, abort, teardown. |
|
||||
|
||||
Stages may be skipped or repeated. Streaming responses tag `upstream` only at start/end (NOT per chunk).
|
||||
|
||||
## RequestId Propagation (AsyncLocalStorage)
|
||||
|
||||
`requestId` is propagated implicitly via Node `AsyncLocalStorage`. Entry edges wrap their handler in `withRequestContext`; every `createLogger`-emitted entry inside the context auto-merges `requestId` from the active store.
|
||||
|
||||
```ts
|
||||
import { withRequestContext, createLogger } from './services/logging';
|
||||
|
||||
const logger = createLogger('proxy:my-edge');
|
||||
|
||||
http.createServer((req, res) => {
|
||||
const requestId = req.headers['x-ccs-request-id'] ?? randomUUID();
|
||||
res.setHeader('x-ccs-request-id', requestId);
|
||||
withRequestContext({ requestId }, async () => {
|
||||
logger.stage('intake', 'request.received', 'inbound');
|
||||
// ... downstream work emits with the same requestId
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Cross-daemon header
|
||||
|
||||
`x-ccs-request-id` round-trips across the proxy edge:
|
||||
- Inbound: if the header is present and matches the UUID-ish guard (`/^[A-Za-z0-9._-]{8,128}$/`), it is reused; otherwise a fresh UUID is minted.
|
||||
- Outbound (response): the resolved id is echoed back via `res.setHeader('x-ccs-request-id', ...)`.
|
||||
- When CCS calls another daemon (copilot, cursor, glmt), forward the active id in the same header so that daemon can correlate.
|
||||
|
||||
### Ordering guarantee
|
||||
|
||||
Emit-time ordering of entries within a single `requestId` is monotonic — the active context is single-threaded relative to the request, so `timestamp` ordering reflects emit order. The UI layer (#1142) consumes this guarantee.
|
||||
|
||||
### What NOT to put in the context
|
||||
|
||||
The ALS context object is mixed into every downstream entry. Never store:
|
||||
- Raw tokens, API keys, refresh tokens, OAuth codes
|
||||
- Raw request/response bodies
|
||||
- User-supplied secrets
|
||||
|
||||
Only benign correlation metadata: `requestId`, `method`, `path`, `command`, `profile`.
|
||||
|
||||
### Worker threads / spawned children
|
||||
|
||||
ALS context is **not** inherited by worker threads or `child_process.spawn` stdio pipes. At those boundaries, mint a fresh `requestId` at the child entry and pass the parent id explicitly via env var or header for correlation.
|
||||
|
||||
## Redaction
|
||||
|
||||
`src/services/logging/log-redaction.ts` is the single source of truth.
|
||||
|
||||
### Sensitive key matcher
|
||||
|
||||
`SENSITIVE_KEY_PATTERN` matches (case-insensitive, with `_` / `-` / camelCase variants):
|
||||
`authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `password`, `password_hash`, `secret`, `client_secret`, `token`, `auth_token`, `access_token`, `refresh_token`, `id_token`, `bearer`, `assertion`, `api_key`, `x-api-key`, `x-goog-api-key`, `management_key`, `copilot_token`, `cursor_session_key`, `oauth_code`, `auth_code`.
|
||||
|
||||
String/object values for matching keys are replaced with `[redacted]`. Numeric/boolean values pass through (e.g., `expires_at` epoch numbers stay readable).
|
||||
|
||||
### Auth-scheme value masking
|
||||
|
||||
Raw string values whose prefix matches `^(Bearer|Basic|Token)\s+\S+` are rewritten to `<scheme> [redacted]` even when nested under non-sensitive keys.
|
||||
|
||||
### Argv redaction
|
||||
|
||||
`redactArgv(argv)` redacts the value following any sensitive flag (`--token`, `--api-key`, `--auth`, `--bearer`, `--secret`, `--client-secret`, `--access-token`, `--refresh-token`, `--id-token`, `--password`).
|
||||
|
||||
### Adding new sensitive keys
|
||||
|
||||
1. Extend `SENSITIVE_KEY_PATTERN` in `src/services/logging/log-redaction.ts`.
|
||||
2. Add a unit test in `tests/unit/services/logging/log-redaction-extended.test.ts`.
|
||||
3. Verify regex stays O(1) per key (no catastrophic backtracking).
|
||||
|
||||
## Contributor Guide
|
||||
|
||||
### When to use `logger.stage()` vs `logger.info()`
|
||||
|
||||
Use `stage()` whenever the entry corresponds to one of the canonical lifecycle stages — this is what observability tooling and the dashboard rely on. Use `info()` / `warn()` / `error()` for one-off events that don't fit a stage.
|
||||
|
||||
### What NOT to log
|
||||
|
||||
- Token values (use metadata: `expires_at`, `scopes`, account display name).
|
||||
- Request/response bodies (sample lengths only).
|
||||
- Authorization headers (log header *names* present, not values).
|
||||
|
||||
### Level guidance
|
||||
|
||||
| Level | Use for |
|
||||
|-------|---------|
|
||||
| `error` | Failures requiring action (cleanup stage). |
|
||||
| `warn` | Recoverable issues (auth rejected, route fallback). |
|
||||
| `info` | Lifecycle stage entries by default. |
|
||||
| `debug` | High-volume detail (per-chunk stream metrics, lock acquire/release). |
|
||||
|
||||
### Level config
|
||||
|
||||
Default level is `info`. Configure via `logging.level` in `~/.ccs/config.yaml`. Streaming providers MUST gate per-chunk metrics behind `debug`.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- All new `LogEntry` fields (`requestId`, `stage`, `latencyMs`, `error`) are optional. Old readers ignore them.
|
||||
- Existing `console.*` UX prints in `src/commands/`, `src/utils/ui.ts`, and similar user-facing paths are intentionally **not** converted to logger.
|
||||
- `/api/logs` reader unchanged in this PR; UI surfacing of new fields tracked under #1142.
|
||||
|
||||
## Future Work
|
||||
|
||||
- UI surfacing of `requestId` / `stage` / `latencyMs` in the dashboard (#1142).
|
||||
- `ccs logs` CLI improvements (filter by `requestId` / `stage`).
|
||||
- Per-stage performance budgets (see #1071).
|
||||
@@ -20,6 +20,7 @@ const candidateRoots = ['tests/unit', 'tests/integration', 'tests/npm', 'src'];
|
||||
// Automated perf-budget enforcement tracked in issue #1071.
|
||||
const slowTests = [
|
||||
'tests/integration/cursor-daemon-lifecycle.test.ts',
|
||||
'tests/integration/logging-request-context.test.ts',
|
||||
'tests/integration/proxy/daemon-lifecycle.test.ts',
|
||||
'tests/unit/commands/persist-command-handler.test.ts',
|
||||
'tests/unit/hooks/browser-mcp-advanced-interactions.test.ts',
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
import type { AccountConfig } from '../config/unified-config-types';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
import { isValidContextGroupName, normalizeContextGroupName } from './account-context';
|
||||
import { createLogger } from '../services/logging';
|
||||
|
||||
const logger = createLogger('auth:profile-registry');
|
||||
|
||||
/**
|
||||
* Profile Registry (Simplified)
|
||||
@@ -182,6 +185,10 @@ export class ProfileRegistry {
|
||||
// Default always stays on implicit 'default' profile (uses ~/.claude/)
|
||||
|
||||
this._write(data);
|
||||
logger.stage('route', 'auth.profile.created', 'Profile created in registry', {
|
||||
profile: name,
|
||||
profileType: metadata.type || 'account',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,6 +242,9 @@ export class ProfileRegistry {
|
||||
}
|
||||
|
||||
this._write(data);
|
||||
logger.stage('cleanup', 'auth.profile.deleted', 'Profile deleted from registry', {
|
||||
profile: name,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -84,7 +84,7 @@ import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from './utils/s
|
||||
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
|
||||
import { createOpenAICompatLaunchSettings } from './utils/openai-compat-launch-settings';
|
||||
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
|
||||
import { createLogger } from './services/logging';
|
||||
import { createLogger, runWithRequestId } from './services/logging';
|
||||
import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides';
|
||||
import type { ProfileDetectionResult } from './auth/profile-detector';
|
||||
import type { BrowserLaunchOverride } from './utils/browser';
|
||||
@@ -1683,5 +1683,35 @@ process.on('SIGINT', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Run main
|
||||
main().catch(handleError);
|
||||
// Run main inside a per-invocation request context so all backend logging
|
||||
// emitted during this CLI run shares a single requestId. CLI text output
|
||||
// (stdout/stderr) is unaffected — the requestId lives in logs only.
|
||||
const cliEntryStartedAt = Date.now();
|
||||
const cliEntryLogger = createLogger('cli:entry');
|
||||
runWithRequestId(() => {
|
||||
cliEntryLogger.stage('intake', 'cli.command.start', 'CLI invocation started', {
|
||||
argv: process.argv.slice(2),
|
||||
});
|
||||
return main()
|
||||
.then(() => {
|
||||
cliEntryLogger.stage(
|
||||
'respond',
|
||||
'cli.command.complete',
|
||||
'CLI invocation completed',
|
||||
{ exitCode: process.exitCode ?? 0 },
|
||||
{ latencyMs: Date.now() - cliEntryStartedAt }
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
const error =
|
||||
err instanceof Error
|
||||
? { name: err.name, message: err.message, stack: err.stack }
|
||||
: { name: 'Error', message: String(err) };
|
||||
cliEntryLogger.stage('cleanup', 'cli.command.failed', 'CLI invocation failed', undefined, {
|
||||
level: 'error',
|
||||
latencyMs: Date.now() - cliEntryStartedAt,
|
||||
error,
|
||||
});
|
||||
handleError(err);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { fail, info, warn, color, ok } from '../../utils/ui';
|
||||
import { createLogger } from '../../services/logging';
|
||||
import { ensureCLIProxyBinary } from '../binary-manager';
|
||||
import { generateConfig } from '../config/config-generator';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
@@ -82,6 +83,8 @@ interface PasteCallbackStartData {
|
||||
const PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS = 3000;
|
||||
const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000;
|
||||
|
||||
const logger = createLogger('cliproxy:auth:oauth');
|
||||
|
||||
export async function requestPasteCallbackStart(
|
||||
provider: CLIProxyProvider,
|
||||
target: ProxyTarget,
|
||||
@@ -800,6 +803,12 @@ export async function triggerOAuth(
|
||||
): Promise<AccountInfo | null> {
|
||||
const oauthConfig = getOAuthConfig(provider);
|
||||
warnOAuthBanRisk(provider);
|
||||
const oauthStartedAt = Date.now();
|
||||
logger.stage('auth', 'cliproxy.oauth.start', 'Triggering OAuth flow', {
|
||||
provider,
|
||||
add: options.add === true,
|
||||
fromUI: options.fromUI === true,
|
||||
});
|
||||
const { verbose = false, add = false, fromUI = false, noIncognito = true } = options;
|
||||
const acceptAgyRisk = options.acceptAgyRisk === true;
|
||||
const { nickname } = options;
|
||||
@@ -1055,6 +1064,24 @@ export async function triggerOAuth(
|
||||
}
|
||||
}
|
||||
|
||||
if (account) {
|
||||
logger.stage(
|
||||
'auth',
|
||||
'cliproxy.oauth.success',
|
||||
'OAuth flow completed successfully',
|
||||
{ provider, accountId: account.id },
|
||||
{ latencyMs: Date.now() - oauthStartedAt }
|
||||
);
|
||||
} else {
|
||||
logger.stage(
|
||||
'cleanup',
|
||||
'cliproxy.oauth.failed',
|
||||
'OAuth flow failed or was cancelled',
|
||||
{ provider },
|
||||
{ level: 'warn', latencyMs: Date.now() - oauthStartedAt }
|
||||
);
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
@@ -1067,6 +1094,9 @@ export async function ensureAuth(
|
||||
options: { verbose?: boolean; headless?: boolean; account?: string } = {}
|
||||
): Promise<boolean> {
|
||||
if (isAuthenticated(provider)) {
|
||||
logger.stage('auth', 'cliproxy.auth.cached', 'Provider already authenticated', {
|
||||
provider,
|
||||
});
|
||||
if (options.verbose) {
|
||||
console.error(`[auth] ${provider} already authenticated`);
|
||||
}
|
||||
@@ -1077,6 +1107,10 @@ export async function ensureAuth(
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.stage('auth', 'cliproxy.auth.required', 'Provider needs authentication', {
|
||||
provider,
|
||||
});
|
||||
|
||||
const oauthConfig = getOAuthConfig(provider);
|
||||
console.log(info(`${oauthConfig.displayName} authentication required`));
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
*/
|
||||
|
||||
import { initUI, header, dim, color, subheader } from '../utils/ui';
|
||||
import { createLogger } from '../services/logging';
|
||||
|
||||
const logger = createLogger('commands:doctor');
|
||||
|
||||
/**
|
||||
* Show help for doctor command
|
||||
@@ -68,15 +71,34 @@ export async function handleDoctorCommand(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
const shouldFix = args.includes('--fix') || args.includes('-f');
|
||||
const startedAt = Date.now();
|
||||
logger.stage('intake', 'doctor.start', 'Doctor health check starting', {
|
||||
fix: shouldFix,
|
||||
});
|
||||
|
||||
const DoctorModule = await import('../management/doctor');
|
||||
const Doctor = DoctorModule.default;
|
||||
const doctor = new Doctor();
|
||||
|
||||
await doctor.runAllChecks();
|
||||
logger.stage(
|
||||
'route',
|
||||
'doctor.checks_complete',
|
||||
'Doctor checks complete',
|
||||
{ healthy: doctor.isHealthy() },
|
||||
{ latencyMs: Date.now() - startedAt }
|
||||
);
|
||||
|
||||
if (shouldFix) {
|
||||
logger.stage('dispatch', 'doctor.fix_start', 'Attempting auto-fix', {});
|
||||
await doctor.fixIssues();
|
||||
logger.stage(
|
||||
'respond',
|
||||
'doctor.fix_complete',
|
||||
'Doctor auto-fix complete',
|
||||
{ healthy: doctor.isHealthy() },
|
||||
{ latencyMs: Date.now() - startedAt }
|
||||
);
|
||||
}
|
||||
|
||||
process.exit(doctor.isHealthy() ? 0 : 1);
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
isCopilotApiInstalled as checkInstalled,
|
||||
getCopilotApiBinPath,
|
||||
} from './copilot-package-manager';
|
||||
import { createLogger } from '../services/logging';
|
||||
|
||||
const logger = createLogger('copilot:auth');
|
||||
|
||||
/**
|
||||
* Get the path to copilot-api's GitHub token file.
|
||||
@@ -108,6 +111,10 @@ export async function getCopilotDebugInfo(): Promise<CopilotDebugInfo | null> {
|
||||
export async function checkAuthStatus(): Promise<CopilotAuthStatus> {
|
||||
// Fast path: check if token file exists (instant, no subprocess)
|
||||
if (hasTokenFile()) {
|
||||
logger.stage('auth', 'copilot.auth.token_present', 'Copilot token file present', {
|
||||
provider: 'copilot',
|
||||
method: 'token_file',
|
||||
});
|
||||
return { authenticated: true };
|
||||
}
|
||||
|
||||
@@ -116,9 +123,20 @@ export async function checkAuthStatus(): Promise<CopilotAuthStatus> {
|
||||
const debugInfo = await getCopilotDebugInfo();
|
||||
|
||||
if (debugInfo?.authenticated) {
|
||||
logger.stage('auth', 'copilot.auth.debug_ok', 'Copilot reports authenticated via debug', {
|
||||
provider: 'copilot',
|
||||
method: 'debug',
|
||||
});
|
||||
return { authenticated: true };
|
||||
}
|
||||
|
||||
logger.stage(
|
||||
'auth',
|
||||
'copilot.auth.unauthenticated',
|
||||
'Copilot is not authenticated',
|
||||
{ provider: 'copilot' },
|
||||
{ level: 'warn' }
|
||||
);
|
||||
return {
|
||||
authenticated: false,
|
||||
};
|
||||
@@ -149,6 +167,11 @@ export function startAuthFlow(): Promise<AuthFlowResult> {
|
||||
console.log('[i] Starting GitHub authentication for Copilot...');
|
||||
console.log('');
|
||||
|
||||
const startedAt = Date.now();
|
||||
logger.stage('auth', 'copilot.auth.start', 'Starting Copilot OAuth device flow', {
|
||||
provider: 'copilot',
|
||||
});
|
||||
|
||||
const proc = spawn(binPath, ['auth'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: process.platform === 'win32',
|
||||
@@ -185,8 +208,22 @@ export function startAuthFlow(): Promise<AuthFlowResult> {
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
logger.stage(
|
||||
'auth',
|
||||
'copilot.auth.complete',
|
||||
'Copilot OAuth device flow completed',
|
||||
{ provider: 'copilot', exitCode: code },
|
||||
{ latencyMs: Date.now() - startedAt }
|
||||
);
|
||||
resolve({ success: true, deviceCode, verificationUrl });
|
||||
} else {
|
||||
logger.stage(
|
||||
'cleanup',
|
||||
'copilot.auth.failed',
|
||||
'Copilot OAuth device flow failed',
|
||||
{ provider: 'copilot', exitCode: code },
|
||||
{ level: 'error', latencyMs: Date.now() - startedAt }
|
||||
);
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Authentication failed with exit code ${code}`,
|
||||
@@ -197,6 +234,17 @@ export function startAuthFlow(): Promise<AuthFlowResult> {
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
logger.stage(
|
||||
'cleanup',
|
||||
'copilot.auth.error',
|
||||
'Failed to start Copilot OAuth process',
|
||||
{ provider: 'copilot' },
|
||||
{
|
||||
level: 'error',
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: { name: err.name, message: err.message },
|
||||
}
|
||||
);
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Failed to start auth: ${err.message}`,
|
||||
|
||||
@@ -14,6 +14,9 @@ import { CopilotConfig, DEFAULT_COPILOT_CONFIG } from '../config/unified-config-
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { getCopilotDir, getCopilotApiBinPath } from './copilot-package-manager';
|
||||
import { verifyProcessOwnership } from '../cursor/daemon-process-ownership';
|
||||
import { createLogger } from '../services/logging';
|
||||
|
||||
const logger = createLogger('copilot:daemon');
|
||||
|
||||
const DAEMON_HEALTH_MARKER = 'server running';
|
||||
const MIN_PORT = 1;
|
||||
@@ -166,6 +169,10 @@ export async function startDaemon(
|
||||
|
||||
// Check if already running
|
||||
if (await isDaemonRunning(config.port)) {
|
||||
logger.stage('dispatch', 'copilot.daemon.already_running', 'Copilot daemon already running', {
|
||||
provider: 'copilot',
|
||||
port: config.port,
|
||||
});
|
||||
return { success: true, pid: getPidFromFile() ?? undefined };
|
||||
}
|
||||
|
||||
@@ -185,6 +192,13 @@ export async function startDaemon(
|
||||
}
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
logger.stage('dispatch', 'copilot.daemon.spawn', 'Spawning Copilot daemon', {
|
||||
provider: 'copilot',
|
||||
port: config.port,
|
||||
accountType: config.account_type,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let proc: ChildProcess;
|
||||
let resolved = false;
|
||||
@@ -198,6 +212,27 @@ export async function startDaemon(
|
||||
}
|
||||
if (!result.success) {
|
||||
removePidFile();
|
||||
logger.stage(
|
||||
'cleanup',
|
||||
'copilot.daemon.start_failed',
|
||||
'Copilot daemon failed to start',
|
||||
{ provider: 'copilot', port: config.port },
|
||||
{
|
||||
level: 'error',
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: result.error
|
||||
? { name: 'CopilotDaemonStartError', message: result.error }
|
||||
: undefined,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
logger.stage(
|
||||
'upstream',
|
||||
'copilot.daemon.ready',
|
||||
'Copilot daemon is ready',
|
||||
{ provider: 'copilot', port: config.port, pid: result.pid },
|
||||
{ latencyMs: Date.now() - startedAt }
|
||||
);
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
@@ -326,6 +361,10 @@ export async function stopDaemon(): Promise<{ success: boolean; error?: string }
|
||||
}
|
||||
|
||||
// Send SIGTERM to the process
|
||||
logger.stage('cleanup', 'copilot.daemon.stop', 'Sending SIGTERM to Copilot daemon', {
|
||||
provider: 'copilot',
|
||||
pid,
|
||||
});
|
||||
process.kill(pid, 'SIGTERM');
|
||||
|
||||
// Wait for process to exit (up to 5 seconds)
|
||||
|
||||
@@ -35,6 +35,9 @@ import {
|
||||
resolveImageAnalysisRuntimeStatus,
|
||||
} from '../utils/hooks';
|
||||
import { stripClaudeCodeEnv } from '../utils/shell-executor';
|
||||
import { createLogger } from '../services/logging';
|
||||
|
||||
const logger = createLogger('copilot:executor');
|
||||
|
||||
interface CopilotImageAnalysisDeps {
|
||||
ensureCliproxyService: typeof ensureCliproxyService;
|
||||
@@ -185,6 +188,12 @@ export async function executeCopilotProfile(
|
||||
): Promise<number> {
|
||||
const { config: normalizedConfig, warnings } = normalizeCopilotConfigWithWarnings(config);
|
||||
|
||||
logger.stage('intake', 'copilot.execute.start', 'Starting Copilot profile execution', {
|
||||
provider: 'copilot',
|
||||
model: normalizedConfig.model,
|
||||
port: normalizedConfig.port,
|
||||
});
|
||||
|
||||
if (warnings.length > 0) {
|
||||
warnings.forEach(({ message }) => console.log(warn(message)));
|
||||
console.log(
|
||||
@@ -216,6 +225,10 @@ export async function executeCopilotProfile(
|
||||
|
||||
// Check authentication
|
||||
const authStatus = await checkAuthStatus();
|
||||
logger.stage('auth', 'copilot.execute.auth_check', 'Checked Copilot auth status', {
|
||||
provider: 'copilot',
|
||||
authenticated: authStatus.authenticated,
|
||||
});
|
||||
if (!authStatus.authenticated) {
|
||||
console.error(fail('Not authenticated with GitHub.'));
|
||||
console.error('');
|
||||
@@ -289,6 +302,7 @@ export async function executeCopilotProfile(
|
||||
console.log('');
|
||||
|
||||
// Spawn Claude CLI
|
||||
const spawnStartedAt = Date.now();
|
||||
return new Promise((resolve) => {
|
||||
const imageAnalysisArgs = imageAnalysisMcpReady
|
||||
? appendThirdPartyImageAnalysisToolArgs(claudeArgs)
|
||||
@@ -302,6 +316,11 @@ export async function executeCopilotProfile(
|
||||
claudeConfigDir,
|
||||
});
|
||||
|
||||
logger.stage('dispatch', 'copilot.execute.spawn', 'Spawning Claude via Copilot proxy', {
|
||||
provider: 'copilot',
|
||||
argCount: launchArgs.length,
|
||||
});
|
||||
|
||||
const proc = spawn(claudeCliPath, launchArgs, {
|
||||
stdio: 'inherit',
|
||||
env: { ...env, ...traceEnv },
|
||||
@@ -309,10 +328,28 @@ export async function executeCopilotProfile(
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
logger.stage(
|
||||
'respond',
|
||||
'copilot.execute.exit',
|
||||
'Claude process exited (Copilot)',
|
||||
{ provider: 'copilot', exitCode: code },
|
||||
{ latencyMs: Date.now() - spawnStartedAt }
|
||||
);
|
||||
resolve(code ?? 0);
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
logger.stage(
|
||||
'cleanup',
|
||||
'copilot.execute.error',
|
||||
'Failed to spawn Claude (Copilot)',
|
||||
{ provider: 'copilot' },
|
||||
{
|
||||
level: 'error',
|
||||
latencyMs: Date.now() - spawnStartedAt,
|
||||
error: { name: err.name, message: err.message },
|
||||
}
|
||||
);
|
||||
console.error(fail(`Failed to start Claude: ${err.message}`));
|
||||
resolve(1);
|
||||
});
|
||||
|
||||
@@ -12,8 +12,11 @@ import * as http from 'http';
|
||||
import type { CursorDaemonConfig, CursorDaemonStatus } from './types';
|
||||
import { getPidFromFile, writePidToFile, removePidFile } from './cursor-daemon-pid';
|
||||
import { verifyDaemonOwnership } from './daemon-process-ownership';
|
||||
import { createLogger } from '../services/logging';
|
||||
export { getPidFromFile, writePidToFile, removePidFile } from './cursor-daemon-pid';
|
||||
|
||||
const logger = createLogger('cursor:daemon');
|
||||
|
||||
/**
|
||||
* Resolve daemon entrypoint candidates for current runtime.
|
||||
* - Dist runtime always uses JS artifact.
|
||||
@@ -125,6 +128,10 @@ export async function startDaemon(
|
||||
): Promise<{ success: boolean; pid?: number; error?: string }> {
|
||||
// Check if already running
|
||||
if (await isDaemonRunning(config.port)) {
|
||||
logger.stage('dispatch', 'cursor.daemon.already_running', 'Cursor daemon already running', {
|
||||
provider: 'cursor',
|
||||
port: config.port,
|
||||
});
|
||||
return { success: true, pid: getPidFromFile() ?? undefined };
|
||||
}
|
||||
|
||||
@@ -141,6 +148,13 @@ export async function startDaemon(
|
||||
};
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
logger.stage('dispatch', 'cursor.daemon.spawn', 'Spawning Cursor daemon', {
|
||||
provider: 'cursor',
|
||||
port: config.port,
|
||||
ghostMode: config.ghost_mode !== false,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let proc: ChildProcess;
|
||||
let resolved = false;
|
||||
@@ -149,7 +163,30 @@ export async function startDaemon(
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
if (checkTimeout) clearTimeout(checkTimeout);
|
||||
if (!result.success) removePidFile();
|
||||
if (!result.success) {
|
||||
removePidFile();
|
||||
logger.stage(
|
||||
'cleanup',
|
||||
'cursor.daemon.start_failed',
|
||||
'Cursor daemon failed to start',
|
||||
{ provider: 'cursor', port: config.port },
|
||||
{
|
||||
level: 'error',
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: result.error
|
||||
? { name: 'CursorDaemonStartError', message: result.error }
|
||||
: undefined,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
logger.stage(
|
||||
'upstream',
|
||||
'cursor.daemon.ready',
|
||||
'Cursor daemon is ready',
|
||||
{ provider: 'cursor', port: config.port, pid: result.pid },
|
||||
{ latencyMs: Date.now() - startedAt }
|
||||
);
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
@@ -270,6 +307,10 @@ export async function stopDaemon(): Promise<{ success: boolean; error?: string }
|
||||
}
|
||||
|
||||
// Send SIGTERM to the process
|
||||
logger.stage('cleanup', 'cursor.daemon.stop', 'Sending SIGTERM to Cursor daemon', {
|
||||
provider: 'cursor',
|
||||
pid,
|
||||
});
|
||||
process.kill(pid, 'SIGTERM');
|
||||
|
||||
// Wait for process to exit (up to 5 seconds)
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
type CursorApiCredentials,
|
||||
} from './cursor-protobuf-schema.js';
|
||||
import { buildCursorConnectHeaders, generateCursorChecksum } from './cursor-client-policy.js';
|
||||
import { createLogger } from '../services/logging';
|
||||
|
||||
const logger = createLogger('cursor:executor');
|
||||
|
||||
import {
|
||||
CursorConnectFrameError,
|
||||
@@ -272,6 +275,13 @@ export class CursorExecutor {
|
||||
const headers = this.buildHeaders(credentials);
|
||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||
|
||||
const startedAt = Date.now();
|
||||
logger.stage('upstream', 'cursor.upstream.request', 'Sending Cursor upstream request', {
|
||||
provider: 'cursor',
|
||||
model,
|
||||
stream,
|
||||
});
|
||||
|
||||
try {
|
||||
// Streaming requests use incremental HTTP/2 → SSE pipeline
|
||||
if (stream) {
|
||||
@@ -294,6 +304,13 @@ export class CursorExecutor {
|
||||
|
||||
if (response.status !== 200) {
|
||||
const errorText = response.body?.toString() || 'Unknown error';
|
||||
logger.stage(
|
||||
'cleanup',
|
||||
'cursor.upstream.error',
|
||||
'Cursor upstream returned non-200 status',
|
||||
{ provider: 'cursor', model, status: response.status },
|
||||
{ level: 'error', latencyMs: Date.now() - startedAt }
|
||||
);
|
||||
const errorResponse = new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
@@ -311,12 +328,31 @@ export class CursorExecutor {
|
||||
}
|
||||
|
||||
const transformedResponse = this.transformProtobufToJSON(response.body, model, body);
|
||||
logger.stage(
|
||||
'transform',
|
||||
'cursor.upstream.transformed',
|
||||
'Cursor response transformed',
|
||||
{ provider: 'cursor', model },
|
||||
{ latencyMs: Date.now() - startedAt }
|
||||
);
|
||||
return { response: transformedResponse, url, headers, transformedBody: body };
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
logger.stage(
|
||||
'cleanup',
|
||||
'cursor.upstream.exception',
|
||||
'Cursor upstream request threw',
|
||||
{ provider: 'cursor', model },
|
||||
{
|
||||
level: 'error',
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: { name: err.name, message: err.message },
|
||||
}
|
||||
);
|
||||
const errorResponse = new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: (error as Error).message,
|
||||
message: err.message,
|
||||
type: 'connection_error',
|
||||
code: '',
|
||||
},
|
||||
|
||||
@@ -23,6 +23,9 @@ import * as https from 'https';
|
||||
import { GlmtTransformer } from './glmt-transformer';
|
||||
import { SSEParser } from './sse-parser';
|
||||
import { DeltaAccumulator } from './delta-accumulator';
|
||||
import { createLogger } from '../services/logging';
|
||||
|
||||
const logger = createLogger('glmt:proxy');
|
||||
|
||||
interface GlmtProxyConfig {
|
||||
verbose?: boolean;
|
||||
@@ -137,6 +140,10 @@ export class GlmtProxy {
|
||||
this.server.listen(0, '127.0.0.1', () => {
|
||||
const address = this.server?.address();
|
||||
this.port = typeof address === 'object' && address ? address.port : 0;
|
||||
logger.stage('intake', 'glmt.proxy.listening', 'GLMT proxy listening', {
|
||||
provider: 'glmt',
|
||||
port: this.port,
|
||||
});
|
||||
// Signal parent process
|
||||
console.log(`PROXY_READY:${this.port}`);
|
||||
|
||||
@@ -448,6 +455,14 @@ export class GlmtProxy {
|
||||
lastError = err;
|
||||
const delay = this.calculateRetryDelay(attempt, retryAfter);
|
||||
|
||||
logger.stage(
|
||||
'upstream',
|
||||
'glmt.upstream.retry',
|
||||
'Retrying GLMT upstream after retryable error',
|
||||
{ provider: 'glmt', attempt: attempt + 1, maxRetries: this.retryConfig.maxRetries },
|
||||
{ level: 'warn' }
|
||||
);
|
||||
|
||||
if (this.verbose) {
|
||||
console.error(
|
||||
`[glmt-proxy] Rate limited, retry ${attempt + 1}/${this.retryConfig.maxRetries} after ${Math.round(delay)}ms`
|
||||
|
||||
@@ -127,9 +127,13 @@ export class GlmtTransformer {
|
||||
return anthropicResponse;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('response.transform_failed', 'GLMT response transformation failed', {
|
||||
message: err.message,
|
||||
});
|
||||
this.logger.stage(
|
||||
'cleanup',
|
||||
'response.transform_failed',
|
||||
'GLMT response transformation failed',
|
||||
undefined,
|
||||
{ level: 'error', error: { name: err.name, message: err.message } }
|
||||
);
|
||||
console.error('[glmt-transformer] Response transformation error:', err);
|
||||
return {
|
||||
id: 'msg_error_' + Date.now(),
|
||||
|
||||
@@ -13,6 +13,9 @@ import ProfileContextSyncLock from './profile-context-sync-lock';
|
||||
import { DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context';
|
||||
import type { AccountContextPolicy } from '../auth/account-context';
|
||||
import { getCcsDir, getCcsHome } from '../utils/config-manager';
|
||||
import { createLogger } from '../services/logging';
|
||||
|
||||
const logger = createLogger('management:instance-manager');
|
||||
|
||||
const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch', 'ccs-image-analysis', 'ccs-browser']);
|
||||
|
||||
@@ -52,6 +55,10 @@ class InstanceManager {
|
||||
await this.contextSyncLock.withLock(profileName, async () => {
|
||||
// Lazy initialization
|
||||
if (!fs.existsSync(instancePath)) {
|
||||
logger.stage('route', 'instance.init', 'Initializing new profile instance', {
|
||||
profile: profileName,
|
||||
bare: options.bare === true,
|
||||
});
|
||||
this.initializeInstance(profileName, instancePath, options);
|
||||
}
|
||||
|
||||
@@ -170,6 +177,9 @@ class InstanceManager {
|
||||
}
|
||||
|
||||
fs.rmSync(instancePath, { recursive: true, force: true });
|
||||
logger.stage('cleanup', 'instance.deleted', 'Profile instance deleted', {
|
||||
profile: profileName,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -207,11 +207,23 @@ export async function handleProxyMessagesRequest(
|
||||
insecureDispatcher?: Dispatcher
|
||||
): Promise<void> {
|
||||
const transformer = new ProxySseStreamTransformer();
|
||||
const startedAt = Date.now();
|
||||
|
||||
logger.stage('intake', 'request.received', 'Proxy /v1/messages request received', {
|
||||
method: req.method || 'POST',
|
||||
remoteAddress: req.socket.remoteAddress || null,
|
||||
});
|
||||
|
||||
if (!validateIncomingProxyAuth(req.headers, expectedAuthToken)) {
|
||||
logger.warn('auth.invalid', 'Rejected proxy message request with invalid auth token', {
|
||||
remoteAddress: req.socket.remoteAddress || null,
|
||||
});
|
||||
logger.stage(
|
||||
'auth',
|
||||
'auth.invalid',
|
||||
'Rejected proxy message request with invalid auth token',
|
||||
{
|
||||
remoteAddress: req.socket.remoteAddress || null,
|
||||
},
|
||||
{ level: 'warn' }
|
||||
);
|
||||
await pipeWebResponseToNode(
|
||||
transformer.error(401, 'authentication_error', 'Missing or invalid local proxy token'),
|
||||
res
|
||||
@@ -219,11 +231,14 @@ export async function handleProxyMessagesRequest(
|
||||
return;
|
||||
}
|
||||
|
||||
logger.stage('auth', 'auth.ok', 'Proxy auth validated');
|
||||
|
||||
let timeoutMs = REQUEST_TIMEOUT_MS;
|
||||
try {
|
||||
const rawBody = await readJsonBody(req);
|
||||
logger.stage('transform', 'request.transform.start', 'Transforming inbound proxy body');
|
||||
const upstream = buildUpstreamRequest(profile, rawBody);
|
||||
logger.info('request.forward', 'Forwarding Anthropic request to OpenAI-compatible upstream', {
|
||||
logger.stage('route', 'request.routed', 'Resolved proxy upstream route', {
|
||||
profileName: upstream.route.profile.profileName,
|
||||
provider: upstream.route.profile.provider,
|
||||
baseUrl: upstream.route.profile.baseUrl,
|
||||
@@ -240,7 +255,8 @@ export async function handleProxyMessagesRequest(
|
||||
res,
|
||||
controller,
|
||||
(source) => {
|
||||
logger.info(
|
||||
logger.stage(
|
||||
'cleanup',
|
||||
'request.disconnect',
|
||||
'Aborting upstream request after local client disconnect',
|
||||
{
|
||||
@@ -252,28 +268,47 @@ export async function handleProxyMessagesRequest(
|
||||
);
|
||||
|
||||
try {
|
||||
logger.stage('dispatch', 'upstream.dispatch', 'Dispatching upstream fetch', {
|
||||
profileName: profile.profileName,
|
||||
});
|
||||
const upstreamResponse = await fetch(
|
||||
resolveOpenAIChatCompletionsUrl(upstream.route.profile.baseUrl),
|
||||
buildFetchInit(upstream.route.profile, upstream.body, controller.signal, insecureDispatcher)
|
||||
);
|
||||
logger.info('response.received', 'Received upstream response', {
|
||||
logger.stage('upstream', 'upstream.response', 'Received upstream response', {
|
||||
profileName: profile.profileName,
|
||||
routedProfileName: upstream.route.profile.profileName,
|
||||
status: upstreamResponse.status,
|
||||
});
|
||||
const response = await transformer.transform(upstreamResponse);
|
||||
await pipeWebResponseToNode(response, res);
|
||||
logger.stage('respond', 'request.respond', 'Proxy response written', undefined, {
|
||||
latencyMs: Date.now() - startedAt,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
cleanupDisconnectHandlers();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown proxy error';
|
||||
logger.error('request.failed', 'Proxy message request failed', {
|
||||
profileName: profile.profileName,
|
||||
error: message,
|
||||
abort: error instanceof Error && error.name === 'AbortError',
|
||||
});
|
||||
const errInfo = {
|
||||
name: error instanceof Error ? error.name : 'Error',
|
||||
message,
|
||||
};
|
||||
logger.stage(
|
||||
'cleanup',
|
||||
'request.failed',
|
||||
'Proxy message request failed',
|
||||
{
|
||||
profileName: profile.profileName,
|
||||
abort: error instanceof Error && error.name === 'AbortError',
|
||||
},
|
||||
{
|
||||
level: 'error',
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: errInfo,
|
||||
}
|
||||
);
|
||||
const status =
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? 502
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import * as http from 'http';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Agent } from 'undici';
|
||||
import type { OpenAICompatProfileConfig } from '../profile-router';
|
||||
import { OPENAI_COMPAT_PROXY_SERVICE_NAME } from '../proxy-daemon-paths';
|
||||
import { createLogger } from '../../services/logging';
|
||||
import { createLogger, withRequestContext } from '../../services/logging';
|
||||
import {
|
||||
handleProxyMessagesRequest,
|
||||
handleProxyModelsRequest,
|
||||
@@ -10,6 +11,18 @@ import {
|
||||
} from './messages-route';
|
||||
import { writeJson } from './http-helpers';
|
||||
|
||||
const REQUEST_ID_HEADER = 'x-ccs-request-id';
|
||||
// Loose UUID-ish guard: accepts UUIDs and similar opaque ids; rejects empty / control chars.
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{8,128}$/;
|
||||
|
||||
function resolveInboundRequestId(headers: http.IncomingHttpHeaders): string {
|
||||
const raw = headers[REQUEST_ID_HEADER];
|
||||
if (typeof raw === 'string' && REQUEST_ID_PATTERN.test(raw.trim())) {
|
||||
return raw.trim();
|
||||
}
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
export interface OpenAICompatProxyServerOptions {
|
||||
profile: OpenAICompatProfileConfig;
|
||||
host?: string;
|
||||
@@ -28,13 +41,25 @@ export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOpt
|
||||
const insecureDispatcher = options.insecure
|
||||
? new Agent({ connect: { rejectUnauthorized: false } })
|
||||
: undefined;
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const method = req.method || 'GET';
|
||||
const requestUrl = req.url || '/';
|
||||
const parsedUrl = new URL(requestUrl, 'http://127.0.0.1');
|
||||
const pathname =
|
||||
parsedUrl.pathname.length > 1 ? parsedUrl.pathname.replace(/\/+$/, '') : parsedUrl.pathname;
|
||||
const server = http.createServer((req, res) => {
|
||||
const requestId = resolveInboundRequestId(req.headers);
|
||||
res.setHeader(REQUEST_ID_HEADER, requestId);
|
||||
void withRequestContext({ requestId }, async () => {
|
||||
const method = req.method || 'GET';
|
||||
const requestUrl = req.url || '/';
|
||||
const parsedUrl = new URL(requestUrl, 'http://127.0.0.1');
|
||||
const pathname =
|
||||
parsedUrl.pathname.length > 1 ? parsedUrl.pathname.replace(/\/+$/, '') : parsedUrl.pathname;
|
||||
await handleProxyRequest(req, res, method, pathname);
|
||||
});
|
||||
});
|
||||
|
||||
async function handleProxyRequest(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
method: string,
|
||||
pathname: string
|
||||
): Promise<void> {
|
||||
if ((method === 'GET' || method === 'HEAD') && pathname === '/health') {
|
||||
if (method === 'HEAD') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
@@ -105,7 +130,7 @@ export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOpt
|
||||
pathname,
|
||||
});
|
||||
writeJson(res, 404, { error: 'Not found' });
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('server.start', 'OpenAI-compatible proxy server listening', {
|
||||
baseUrl: `http://${host}:${options.port}`,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { createLogger } from './logger';
|
||||
export type { Logger, StageOptions } from './logger';
|
||||
export { getResolvedLoggingConfig, invalidateLoggingConfigCache } from './log-config';
|
||||
export { readLogEntries, readLogSourceSummaries, normalizeLogQueryLevel } from './log-reader';
|
||||
export { pruneExpiredLogArchives } from './log-storage';
|
||||
@@ -10,4 +11,27 @@ export {
|
||||
getNativeLogsDir,
|
||||
isPathInsideDirectory,
|
||||
} from './log-paths';
|
||||
export type { LogEntry, LogSourceSummary, LoggingLevel, ReadLogEntriesOptions } from './log-types';
|
||||
export { getRecentLogEntries, clearRecentLogEntries } from './log-buffer';
|
||||
export {
|
||||
withRequestContext,
|
||||
runWithRequestId,
|
||||
getRequestContext,
|
||||
getRequestId,
|
||||
mergeRequestContext,
|
||||
} from './log-context';
|
||||
export type { RequestContext } from './log-context';
|
||||
export {
|
||||
LOG_LEVELS,
|
||||
LOG_STAGES,
|
||||
shouldWriteLogLevel,
|
||||
isLoggingLevel,
|
||||
isLogStage,
|
||||
} from './log-types';
|
||||
export type {
|
||||
LogEntry,
|
||||
LogErrorInfo,
|
||||
LogSourceSummary,
|
||||
LogStage,
|
||||
LoggingLevel,
|
||||
ReadLogEntriesOptions,
|
||||
} from './log-types';
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { AsyncLocalStorage } from 'async_hooks';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
/**
|
||||
* Per-request context carried via Node.js {@link AsyncLocalStorage}.
|
||||
*
|
||||
* MUST contain only non-sensitive correlation metadata. NEVER store tokens,
|
||||
* secrets, raw bodies, or other sensitive material in this object — values
|
||||
* leak into every downstream log entry emitted within the context.
|
||||
*/
|
||||
export interface RequestContext {
|
||||
/** UUID-shaped correlation id; round-trips via `x-ccs-request-id` header. */
|
||||
requestId: string;
|
||||
/** Optional benign request metadata (method, path, command name, etc.). */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const storage = new AsyncLocalStorage<RequestContext>();
|
||||
|
||||
/**
|
||||
* Run `fn` inside a fresh request context. Use ONLY at request entry edges
|
||||
* (HTTP handlers, CLI command dispatch, daemon inbound boundaries).
|
||||
*
|
||||
* Never call from shared/reused infrastructure — that would leak the requestId
|
||||
* to unrelated callers. Listeners that need to inherit context MUST be
|
||||
* registered inside the `als.run()` callback.
|
||||
*/
|
||||
export function withRequestContext<T>(ctx: RequestContext, fn: () => T): T {
|
||||
return storage.run(ctx, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper that mints a fresh UUID requestId and runs `fn` under it.
|
||||
* Returns the requestId so callers can echo it back via response headers.
|
||||
*/
|
||||
export function runWithRequestId<T>(fn: () => T): { requestId: string; result: T } {
|
||||
const requestId = randomUUID();
|
||||
const result = withRequestContext({ requestId }, fn);
|
||||
return { requestId, result };
|
||||
}
|
||||
|
||||
/** Read the active request context, or `undefined` if not inside one. */
|
||||
export function getRequestContext(): RequestContext | undefined {
|
||||
return storage.getStore();
|
||||
}
|
||||
|
||||
/** Read just the active requestId, or `undefined` if not inside a context. */
|
||||
export function getRequestId(): string | undefined {
|
||||
return storage.getStore()?.requestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge any active request context into the supplied context object,
|
||||
* preferring explicit keys on the input. Existing `requestId` on `extra` wins
|
||||
* (callers may explicitly override; e.g., for cross-daemon correlation).
|
||||
*/
|
||||
export function mergeRequestContext<T extends Record<string, unknown>>(extra: T): T {
|
||||
const ctx = storage.getStore();
|
||||
if (!ctx) return extra;
|
||||
return { ...ctx, ...extra } as T;
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
/**
|
||||
* Sensitive log key matcher (single source of truth).
|
||||
*
|
||||
* Add new patterns conservatively. Numeric/boolean values are passed through
|
||||
* even when their key matches (e.g., `expires_at` epoch numbers stay readable);
|
||||
* only string and object values are redacted.
|
||||
*/
|
||||
const SENSITIVE_KEY_PATTERN =
|
||||
/^(authorization|cookie|set-cookie|password|password_hash|secret|token|api[_-]?key|management[_-]?key)$/i;
|
||||
/^(authorization|proxy[_-]?authorization|cookie|set-cookie|password|password_hash|secret|client[_-]?secret|token|auth[_-]?token|access[_-]?token|refresh[_-]?token|id[_-]?token|bearer|assertion|api[_-]?key|x[_-]?api[_-]?key|x[_-]?goog[_-]?api[_-]?key|management[_-]?key|copilot[_-]?token|cursor[_-]?session[_-]?key|oauth[_-]?code|auth[_-]?code)$/i;
|
||||
|
||||
/** CLI flags whose following argument should be redacted in argv arrays. */
|
||||
const SENSITIVE_ARGV_FLAG_PATTERN =
|
||||
/^--(token|api[_-]?key|auth|auth[_-]?token|secret|bearer|password|client[_-]?secret|refresh[_-]?token|access[_-]?token|id[_-]?token)$/i;
|
||||
|
||||
/** Bearer/Basic/Token auth-scheme prefix in raw string values. */
|
||||
const AUTH_SCHEME_VALUE_PATTERN = /^(Bearer|Basic|Token)\s+\S+/;
|
||||
|
||||
const MAX_STRING_LENGTH = 2000;
|
||||
const MAX_DEPTH = 5;
|
||||
|
||||
@@ -10,6 +25,12 @@ function truncateString(value: string): string {
|
||||
return `${value.slice(0, MAX_STRING_LENGTH)}...[truncated]`;
|
||||
}
|
||||
|
||||
function maskAuthSchemeValue(value: string): string {
|
||||
const match = AUTH_SCHEME_VALUE_PATTERN.exec(value);
|
||||
if (!match) return value;
|
||||
return `${match[1]} [redacted]`;
|
||||
}
|
||||
|
||||
function sanitizeValue(value: unknown, depth: number): unknown {
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
@@ -20,7 +41,7 @@ function sanitizeValue(value: unknown, depth: number): unknown {
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return truncateString(value);
|
||||
return truncateString(maskAuthSchemeValue(value));
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
@@ -60,3 +81,23 @@ export function redactContext(
|
||||
|
||||
return sanitizeValue(context, 0) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact sensitive values from a CLI argv array (e.g. for spawn-arg logging).
|
||||
*
|
||||
* Pairs every sensitive flag (`--token`, `--api-key`, etc.) with its following
|
||||
* argument and replaces that argument with `[redacted]`. Non-sensitive args
|
||||
* pass through unchanged.
|
||||
*/
|
||||
export function redactArgv(argv: readonly string[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
out.push(arg);
|
||||
if (SENSITIVE_ARGV_FLAG_PATTERN.test(arg) && i + 1 < argv.length) {
|
||||
out.push('[redacted]');
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,47 @@ import type { LoggingConfig, LoggingLevel } from '../../config/unified-config-ty
|
||||
|
||||
export type { LoggingConfig, LoggingLevel };
|
||||
|
||||
/**
|
||||
* Canonical request lifecycle stages.
|
||||
*
|
||||
* Order represents typical flow but stages may be skipped or repeated.
|
||||
* - intake: inbound request received at an entry edge (HTTP handler, CLI dispatch)
|
||||
* - route: destination/profile/target resolution
|
||||
* - auth: authentication/authorization (token exchange, profile auth)
|
||||
* - dispatch: outbound request prepared / child process spawned
|
||||
* - upstream: upstream call in flight (provider HTTP / spawned child running)
|
||||
* - transform: payload translation (request/response shape conversion)
|
||||
* - respond: response written / dispatched to caller (latencyMs typically populated here)
|
||||
* - cleanup: error path, abort, teardown
|
||||
*/
|
||||
export type LogStage =
|
||||
| 'intake'
|
||||
| 'route'
|
||||
| 'auth'
|
||||
| 'dispatch'
|
||||
| 'upstream'
|
||||
| 'transform'
|
||||
| 'respond'
|
||||
| 'cleanup';
|
||||
|
||||
export const LOG_STAGES: readonly LogStage[] = [
|
||||
'intake',
|
||||
'route',
|
||||
'auth',
|
||||
'dispatch',
|
||||
'upstream',
|
||||
'transform',
|
||||
'respond',
|
||||
'cleanup',
|
||||
] as const;
|
||||
|
||||
export interface LogErrorInfo {
|
||||
name: string;
|
||||
message: string;
|
||||
code?: string;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
@@ -12,6 +53,14 @@ export interface LogEntry {
|
||||
processId: number;
|
||||
runId: string;
|
||||
context?: Record<string, unknown>;
|
||||
/** Correlates entries belonging to a single inbound request across stages. */
|
||||
requestId?: string;
|
||||
/** Lifecycle stage tag — see {@link LogStage}. */
|
||||
stage?: LogStage;
|
||||
/** Elapsed time in milliseconds, typically attached to `respond`/`cleanup`. */
|
||||
latencyMs?: number;
|
||||
/** Structured error metadata; never stores raw token strings. */
|
||||
error?: LogErrorInfo;
|
||||
}
|
||||
|
||||
export interface LogSourceSummary {
|
||||
@@ -45,3 +94,7 @@ export function shouldWriteLogLevel(level: LoggingLevel, configuredLevel: Loggin
|
||||
export function isLoggingLevel(value: string | undefined): value is LoggingLevel {
|
||||
return typeof value === 'string' && LOG_LEVELS.includes(value as LoggingLevel);
|
||||
}
|
||||
|
||||
export function isLogStage(value: string | undefined): value is LogStage {
|
||||
return typeof value === 'string' && LOG_STAGES.includes(value as LogStage);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getResolvedLoggingConfig } from './log-config';
|
||||
import { getRequestContext } from './log-context';
|
||||
import { redactContext } from './log-redaction';
|
||||
import { appendStructuredLogEntry } from './log-storage';
|
||||
import type { LogEntry, LoggingLevel } from './log-types';
|
||||
import type { LogEntry, LogStage, LoggingLevel } from './log-types';
|
||||
|
||||
const processRunId = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
interface CreateEntryOptions {
|
||||
stage?: LogStage;
|
||||
latencyMs?: number;
|
||||
error?: LogEntry['error'];
|
||||
}
|
||||
|
||||
function createEntry(
|
||||
source: string,
|
||||
level: LoggingLevel,
|
||||
event: string,
|
||||
message: string,
|
||||
context: Record<string, unknown>
|
||||
context: Record<string, unknown>,
|
||||
options: CreateEntryOptions = {}
|
||||
): LogEntry {
|
||||
const config = getResolvedLoggingConfig();
|
||||
return {
|
||||
const reqCtx = getRequestContext();
|
||||
const entry: LogEntry = {
|
||||
id: randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
@@ -25,6 +34,20 @@ function createEntry(
|
||||
runId: processRunId,
|
||||
context: config.redact ? redactContext(context) : context,
|
||||
};
|
||||
if (reqCtx?.requestId) entry.requestId = reqCtx.requestId;
|
||||
if (options.stage) entry.stage = options.stage;
|
||||
if (typeof options.latencyMs === 'number') entry.latencyMs = options.latencyMs;
|
||||
if (options.error) entry.error = options.error;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export interface StageOptions {
|
||||
/** Optional level override; default `'info'`. */
|
||||
level?: LoggingLevel;
|
||||
/** Optional latency in ms (typically attached to `respond`/`cleanup`). */
|
||||
latencyMs?: number;
|
||||
/** Optional structured error info for `cleanup` stages. */
|
||||
error?: LogEntry['error'];
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
@@ -33,6 +56,20 @@ export interface Logger {
|
||||
info(event: string, message: string, context?: Record<string, unknown>): void;
|
||||
warn(event: string, message: string, context?: Record<string, unknown>): void;
|
||||
error(event: string, message: string, context?: Record<string, unknown>): void;
|
||||
/**
|
||||
* Emit a stage-tagged log entry.
|
||||
*
|
||||
* Locked signature: `stage(stage, event, message, context?, options?)`.
|
||||
* `options.level` defaults to `'info'`. Use `options.latencyMs` on
|
||||
* `respond`/`cleanup`. Use `options.error` for structured error info.
|
||||
*/
|
||||
stage(
|
||||
stage: LogStage,
|
||||
event: string,
|
||||
message: string,
|
||||
context?: Record<string, unknown>,
|
||||
options?: StageOptions
|
||||
): void;
|
||||
}
|
||||
|
||||
export function createLogger(source: string, baseContext: Record<string, unknown> = {}): Logger {
|
||||
@@ -40,10 +77,11 @@ export function createLogger(source: string, baseContext: Record<string, unknown
|
||||
level: LoggingLevel,
|
||||
event: string,
|
||||
message: string,
|
||||
context?: Record<string, unknown>
|
||||
context?: Record<string, unknown>,
|
||||
extra: CreateEntryOptions = {}
|
||||
) => {
|
||||
appendStructuredLogEntry(
|
||||
createEntry(source, level, event, message, { ...baseContext, ...(context || {}) })
|
||||
createEntry(source, level, event, message, { ...baseContext, ...(context || {}) }, extra)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -63,5 +101,13 @@ export function createLogger(source: string, baseContext: Record<string, unknown
|
||||
error(event, message, context) {
|
||||
write('error', event, message, context);
|
||||
},
|
||||
stage(stage, event, message, context, options) {
|
||||
const level = options?.level ?? 'info';
|
||||
write(level, event, message, context, {
|
||||
stage,
|
||||
latencyMs: options?.latencyMs,
|
||||
error: options?.error,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ import { getWebSearchHookEnv } from '../utils/websearch-manager';
|
||||
import { appendBrowserToolArgs } from '../utils/browser';
|
||||
import { wireChildProcessSignals } from '../utils/signal-forwarder';
|
||||
import { runCleanup } from '../errors';
|
||||
import { createLogger } from '../services/logging';
|
||||
|
||||
const adapterLogger = createLogger('targets:claude');
|
||||
|
||||
export class ClaudeAdapter implements TargetAdapter {
|
||||
readonly type: TargetType = 'claude';
|
||||
@@ -101,6 +104,13 @@ export class ClaudeAdapter implements TargetAdapter {
|
||||
const isPowerShellScript = isWindows && /\.ps1$/i.test(claudeCli);
|
||||
const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli);
|
||||
|
||||
const spawnStartedAt = Date.now();
|
||||
adapterLogger.stage('dispatch', 'target.spawn', 'Spawning Claude CLI child process', {
|
||||
target: 'claude',
|
||||
claudeCli,
|
||||
argCount: args.length,
|
||||
});
|
||||
|
||||
let child: ChildProcess;
|
||||
if (isPowerShellScript) {
|
||||
child = spawn(
|
||||
@@ -128,6 +138,16 @@ export class ClaudeAdapter implements TargetAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
adapterLogger.stage(
|
||||
'respond',
|
||||
'target.exit',
|
||||
'Claude CLI child process exited',
|
||||
{ target: 'claude', exitCode: code, signal },
|
||||
{ latencyMs: Date.now() - spawnStartedAt }
|
||||
);
|
||||
});
|
||||
|
||||
wireChildProcessSignals(child, async (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EACCES') {
|
||||
console.error(`[X] Claude CLI is not executable: ${claudeCli}`);
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
getCodexBinaryInfo,
|
||||
readCodexVersion,
|
||||
} from './codex-detector';
|
||||
import { createLogger } from '../services/logging';
|
||||
|
||||
const adapterLogger = createLogger('targets:codex');
|
||||
|
||||
const CODEX_RUNTIME_PROVIDER_ID = 'ccs_runtime';
|
||||
const CODEX_RUNTIME_ENV_KEY = 'CCS_CODEX_API_KEY';
|
||||
@@ -310,6 +313,13 @@ export class CodexAdapter implements TargetAdapter {
|
||||
const isPowerShellScript = isWindows && /\.ps1$/i.test(codexPath);
|
||||
const needsShell = isWindows && /\.(cmd|bat)$/i.test(codexPath);
|
||||
|
||||
const spawnStartedAt = Date.now();
|
||||
adapterLogger.stage('dispatch', 'target.spawn', 'Spawning Codex CLI child process', {
|
||||
target: 'codex',
|
||||
codexPath,
|
||||
argCount: args.length,
|
||||
});
|
||||
|
||||
let child: ChildProcess;
|
||||
if (isPowerShellScript) {
|
||||
child = spawn(
|
||||
@@ -329,6 +339,16 @@ export class CodexAdapter implements TargetAdapter {
|
||||
child = spawn(codexPath, args, { stdio: 'inherit', windowsHide: true, env: launchEnv });
|
||||
}
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
adapterLogger.stage(
|
||||
'respond',
|
||||
'target.exit',
|
||||
'Codex CLI child process exited',
|
||||
{ target: 'codex', exitCode: code, signal },
|
||||
{ latencyMs: Date.now() - spawnStartedAt }
|
||||
);
|
||||
});
|
||||
|
||||
wireChildProcessSignals(child, (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EACCES') {
|
||||
console.error(`[X] Codex CLI is not executable: ${codexPath}`);
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
} from '../utils/shell-executor';
|
||||
import { wireChildProcessSignals } from '../utils/signal-forwarder';
|
||||
import { runCleanup } from '../errors';
|
||||
import { createLogger } from '../services/logging';
|
||||
|
||||
const adapterLogger = createLogger('targets:droid');
|
||||
|
||||
export class DroidAdapter implements TargetAdapter {
|
||||
readonly type: TargetType = 'droid';
|
||||
@@ -122,6 +125,13 @@ export class DroidAdapter implements TargetAdapter {
|
||||
const isPowerShellScript = isWindows && /\.ps1$/i.test(droidPath);
|
||||
const needsShell = isWindows && /\.(cmd|bat)$/i.test(droidPath);
|
||||
|
||||
const spawnStartedAt = Date.now();
|
||||
adapterLogger.stage('dispatch', 'target.spawn', 'Spawning Droid CLI child process', {
|
||||
target: 'droid',
|
||||
droidPath,
|
||||
argCount: args.length,
|
||||
});
|
||||
|
||||
let child: ChildProcess;
|
||||
if (isPowerShellScript) {
|
||||
child = spawn(
|
||||
@@ -149,6 +159,16 @@ export class DroidAdapter implements TargetAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
adapterLogger.stage(
|
||||
'respond',
|
||||
'target.exit',
|
||||
'Droid CLI child process exited',
|
||||
{ target: 'droid', exitCode: code, signal },
|
||||
{ latencyMs: Date.now() - spawnStartedAt }
|
||||
);
|
||||
});
|
||||
|
||||
wireChildProcessSignals(child, (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EACCES') {
|
||||
console.error(`[X] Droid CLI is not executable: ${droidPath}`);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { createEmptyUnifiedConfig } from '../../src/config/unified-config-types';
|
||||
import { saveUnifiedConfig } from '../../src/config/unified-config-loader';
|
||||
import {
|
||||
clearRecentLogEntries,
|
||||
createLogger,
|
||||
getRecentLogEntries,
|
||||
invalidateLoggingConfigCache,
|
||||
withRequestContext,
|
||||
} from '../../src/services/logging';
|
||||
|
||||
describe('logging request context (integration)', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-log-context-int-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
clearRecentLogEntries();
|
||||
invalidateLoggingConfigCache();
|
||||
const config = createEmptyUnifiedConfig();
|
||||
config.logging = { ...config.logging, enabled: true, level: 'debug', redact: false };
|
||||
saveUnifiedConfig(config);
|
||||
invalidateLoggingConfigCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome === undefined) delete process.env.CCS_HOME;
|
||||
else process.env.CCS_HOME = originalCcsHome;
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
clearRecentLogEntries();
|
||||
invalidateLoggingConfigCache();
|
||||
});
|
||||
|
||||
it('correlates >=3 stage entries by requestId across nested async calls', async () => {
|
||||
const logger = createLogger('test:integration');
|
||||
|
||||
await withRequestContext({ requestId: 'test-req-1' }, async () => {
|
||||
logger.stage('intake', 'request.received', 'inbound');
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
logger.stage('dispatch', 'upstream.dispatch', 'outbound');
|
||||
await Promise.resolve();
|
||||
logger.stage('respond', 'request.respond', 'sent', undefined, { latencyMs: 5 });
|
||||
});
|
||||
|
||||
const entries = getRecentLogEntries().filter((e) => e.requestId === 'test-req-1');
|
||||
expect(entries.length).toBeGreaterThanOrEqual(3);
|
||||
const stages = entries.map((e) => e.stage);
|
||||
expect(stages).toContain('intake');
|
||||
expect(stages).toContain('dispatch');
|
||||
expect(stages).toContain('respond');
|
||||
// Emit-time ordering guarantee within a single requestId.
|
||||
const timestamps = entries.map((e) => Date.parse(e.timestamp));
|
||||
const sorted = [...timestamps].sort((a, b) => a - b);
|
||||
expect(timestamps).toEqual(sorted);
|
||||
});
|
||||
|
||||
it('isolates parallel request contexts (no cross-contamination)', async () => {
|
||||
const logger = createLogger('test:integration');
|
||||
|
||||
await Promise.all([
|
||||
withRequestContext({ requestId: 'test-req-A' }, async () => {
|
||||
logger.stage('intake', 'a.start', 'a-in');
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
logger.stage('respond', 'a.end', 'a-out');
|
||||
}),
|
||||
withRequestContext({ requestId: 'test-req-B' }, async () => {
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
logger.stage('intake', 'b.start', 'b-in');
|
||||
logger.stage('respond', 'b.end', 'b-out');
|
||||
}),
|
||||
]);
|
||||
|
||||
const entries = getRecentLogEntries();
|
||||
const a = entries.filter((e) => e.requestId === 'test-req-A');
|
||||
const b = entries.filter((e) => e.requestId === 'test-req-B');
|
||||
expect(a.length).toBe(2);
|
||||
expect(b.length).toBe(2);
|
||||
// No A entry leaks into B and vice-versa
|
||||
expect(a.every((e) => e.event.startsWith('a.'))).toBe(true);
|
||||
expect(b.every((e) => e.event.startsWith('b.'))).toBe(true);
|
||||
});
|
||||
|
||||
it('emits no requestId outside any context', () => {
|
||||
const logger = createLogger('test:integration');
|
||||
logger.info('plain.event', 'no-context');
|
||||
const [entry] = getRecentLogEntries();
|
||||
expect(entry.requestId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
getRequestContext,
|
||||
getRequestId,
|
||||
mergeRequestContext,
|
||||
runWithRequestId,
|
||||
withRequestContext,
|
||||
} from '../../../../src/services/logging/log-context';
|
||||
|
||||
describe('log-context (AsyncLocalStorage)', () => {
|
||||
it('returns undefined outside any context', () => {
|
||||
expect(getRequestContext()).toBeUndefined();
|
||||
expect(getRequestId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes the active context inside withRequestContext', () => {
|
||||
withRequestContext({ requestId: 'req-1', method: 'POST' }, () => {
|
||||
const ctx = getRequestContext();
|
||||
expect(ctx?.requestId).toBe('req-1');
|
||||
expect(ctx?.method).toBe('POST');
|
||||
expect(getRequestId()).toBe('req-1');
|
||||
});
|
||||
expect(getRequestContext()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('isolates parallel async tasks (no cross-leak)', async () => {
|
||||
const observed: string[] = [];
|
||||
|
||||
await Promise.all([
|
||||
withRequestContext({ requestId: 'req-A' }, async () => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
observed.push(`A:${getRequestId()}`);
|
||||
}),
|
||||
withRequestContext({ requestId: 'req-B' }, async () => {
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
observed.push(`B:${getRequestId()}`);
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(observed.sort()).toEqual(['A:req-A', 'B:req-B']);
|
||||
});
|
||||
|
||||
it('runWithRequestId mints a UUID and exposes it inside fn', () => {
|
||||
let captured: string | undefined;
|
||||
const { requestId, result } = runWithRequestId(() => {
|
||||
captured = getRequestId();
|
||||
return 42;
|
||||
});
|
||||
expect(result).toBe(42);
|
||||
expect(captured).toBe(requestId);
|
||||
expect(requestId).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
});
|
||||
|
||||
it('mergeRequestContext returns input unchanged outside ALS', () => {
|
||||
const merged = mergeRequestContext({ foo: 'bar' });
|
||||
expect(merged).toEqual({ foo: 'bar' });
|
||||
});
|
||||
|
||||
it('mergeRequestContext merges active context, with input overriding ctx', () => {
|
||||
withRequestContext({ requestId: 'req-X', method: 'GET' }, () => {
|
||||
const merged = mergeRequestContext({ method: 'POST', extra: 1 });
|
||||
expect(merged).toEqual({ requestId: 'req-X', method: 'POST', extra: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves emit-time ordering within a single requestId', async () => {
|
||||
const order: number[] = [];
|
||||
await withRequestContext({ requestId: 'req-order' }, async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await Promise.resolve();
|
||||
order.push(i);
|
||||
}
|
||||
});
|
||||
expect(order).toEqual([0, 1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
redactArgv,
|
||||
redactContext,
|
||||
} from '../../../../src/services/logging/log-redaction';
|
||||
|
||||
describe('log redaction (extended sensitive keys)', () => {
|
||||
const newSensitiveKeys = [
|
||||
'refresh_token',
|
||||
'id_token',
|
||||
'access_token',
|
||||
'client_secret',
|
||||
'bearer',
|
||||
'assertion',
|
||||
'copilot_token',
|
||||
'copilot-token',
|
||||
'cursor_session_key',
|
||||
'cursor-session-key',
|
||||
'x-api-key',
|
||||
'x_goog_api_key',
|
||||
'proxy-authorization',
|
||||
'oauth_code',
|
||||
'auth_code',
|
||||
'auth_token',
|
||||
];
|
||||
|
||||
it.each(newSensitiveKeys)('redacts %s key (top-level)', (key) => {
|
||||
const redacted = redactContext({ [key]: 'secret-value', safe: 'ok' });
|
||||
expect(redacted[key]).toBe('[redacted]');
|
||||
expect(redacted.safe).toBe('ok');
|
||||
});
|
||||
|
||||
it('redacts new keys nested under headers', () => {
|
||||
const redacted = redactContext({
|
||||
headers: {
|
||||
'x-api-key': 'sk-xxx',
|
||||
'proxy-authorization': 'Bearer abc',
|
||||
'copilot-token': 'gho_xxx',
|
||||
},
|
||||
});
|
||||
expect(redacted).toEqual({
|
||||
headers: {
|
||||
'x-api-key': '[redacted]',
|
||||
'proxy-authorization': '[redacted]',
|
||||
'copilot-token': '[redacted]',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts Bearer/Basic/Token scheme prefix in raw string values', () => {
|
||||
const redacted = redactContext({
|
||||
raw: 'Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig',
|
||||
basic: 'Basic dXNlcjpwYXNz',
|
||||
tokenLine: 'Token abc.def',
|
||||
plain: 'no-scheme-here',
|
||||
});
|
||||
expect(redacted.raw).toBe('Bearer [redacted]');
|
||||
expect(redacted.basic).toBe('Basic [redacted]');
|
||||
expect(redacted.tokenLine).toBe('Token [redacted]');
|
||||
expect(redacted.plain).toBe('no-scheme-here');
|
||||
});
|
||||
|
||||
it('passes numeric/boolean values through even if key matches', () => {
|
||||
// expires_at is not a sensitive key; ensure non-string sensitive values
|
||||
// would also pass through if pattern matched (defense check).
|
||||
const redacted = redactContext({
|
||||
access_token: 'real-secret',
|
||||
expires_at: 1234567890,
|
||||
enabled: true,
|
||||
});
|
||||
expect(redacted.access_token).toBe('[redacted]');
|
||||
expect(redacted.expires_at).toBe(1234567890);
|
||||
expect(redacted.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('recurses through arrays of objects', () => {
|
||||
const redacted = redactContext({
|
||||
tokens: [
|
||||
{ access_token: 'a' },
|
||||
{ refresh_token: 'b' },
|
||||
{ id_token: 'c', meta: 'kept' },
|
||||
],
|
||||
});
|
||||
expect(redacted).toEqual({
|
||||
tokens: [
|
||||
{ access_token: '[redacted]' },
|
||||
{ refresh_token: '[redacted]' },
|
||||
{ id_token: '[redacted]', meta: 'kept' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('fuzz-style: many random token-shaped keys all redact', () => {
|
||||
const variants = ['token', 'auth_token', 'refresh-token', 'id-token', 'X-API-KEY'];
|
||||
for (const v of variants) {
|
||||
const out = redactContext({ [v]: 'secret' });
|
||||
expect(out[v]).toBe('[redacted]');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactArgv', () => {
|
||||
it('redacts the value following sensitive flags', () => {
|
||||
expect(redactArgv(['--api-key', 'secret', '--other', 'ok'])).toEqual([
|
||||
'--api-key',
|
||||
'[redacted]',
|
||||
'--other',
|
||||
'ok',
|
||||
]);
|
||||
});
|
||||
|
||||
it('redacts multiple sensitive flags', () => {
|
||||
expect(
|
||||
redactArgv(['--token', 'a', '--secret', 'b', '--bearer', 'c', '--keep', 'd'])
|
||||
).toEqual(['--token', '[redacted]', '--secret', '[redacted]', '--bearer', '[redacted]', '--keep', 'd']);
|
||||
});
|
||||
|
||||
it('passes argv unchanged when no sensitive flags present', () => {
|
||||
expect(redactArgv(['build', '--watch', '--out', 'dist'])).toEqual([
|
||||
'build',
|
||||
'--watch',
|
||||
'--out',
|
||||
'dist',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles trailing sensitive flag with no value', () => {
|
||||
expect(redactArgv(['--api-key'])).toEqual(['--api-key']);
|
||||
});
|
||||
|
||||
it('redacts kebab and snake case flag variants', () => {
|
||||
expect(redactArgv(['--api_key', 'x', '--auth-token', 'y'])).toEqual([
|
||||
'--api_key',
|
||||
'[redacted]',
|
||||
'--auth-token',
|
||||
'[redacted]',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { createEmptyUnifiedConfig } from '../../../../src/config/unified-config-types';
|
||||
import { saveUnifiedConfig } from '../../../../src/config/unified-config-loader';
|
||||
import {
|
||||
clearRecentLogEntries,
|
||||
getRecentLogEntries,
|
||||
} from '../../../../src/services/logging/log-buffer';
|
||||
import { invalidateLoggingConfigCache } from '../../../../src/services/logging/log-config';
|
||||
import { withRequestContext } from '../../../../src/services/logging/log-context';
|
||||
import { createLogger } from '../../../../src/services/logging/logger';
|
||||
|
||||
describe('Logger.stage()', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-logger-stages-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
clearRecentLogEntries();
|
||||
invalidateLoggingConfigCache();
|
||||
const config = createEmptyUnifiedConfig();
|
||||
config.logging = {
|
||||
...config.logging,
|
||||
enabled: true,
|
||||
level: 'debug',
|
||||
redact: false,
|
||||
};
|
||||
saveUnifiedConfig(config);
|
||||
invalidateLoggingConfigCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome === undefined) delete process.env.CCS_HOME;
|
||||
else process.env.CCS_HOME = originalCcsHome;
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
clearRecentLogEntries();
|
||||
invalidateLoggingConfigCache();
|
||||
});
|
||||
|
||||
it('emits a stage-tagged entry with default info level', () => {
|
||||
const logger = createLogger('unit:stages');
|
||||
logger.stage('upstream', 'fetch.start', 'starting upstream fetch');
|
||||
const entries = getRecentLogEntries();
|
||||
expect(entries.length).toBe(1);
|
||||
expect(entries[0].stage).toBe('upstream');
|
||||
expect(entries[0].level).toBe('info');
|
||||
expect(entries[0].event).toBe('fetch.start');
|
||||
});
|
||||
|
||||
it('respects a level override and includes latencyMs + error', () => {
|
||||
const logger = createLogger('unit:stages');
|
||||
logger.stage('cleanup', 'request.failed', 'upstream failed', undefined, {
|
||||
level: 'error',
|
||||
latencyMs: 123,
|
||||
error: { name: 'Error', message: 'boom' },
|
||||
});
|
||||
const [entry] = getRecentLogEntries();
|
||||
expect(entry.level).toBe('error');
|
||||
expect(entry.latencyMs).toBe(123);
|
||||
expect(entry.error?.message).toBe('boom');
|
||||
});
|
||||
|
||||
it('auto-merges requestId from active ALS context', () => {
|
||||
const logger = createLogger('unit:stages');
|
||||
withRequestContext({ requestId: 'req-9' }, () => {
|
||||
logger.stage('intake', 'http.request', 'in');
|
||||
});
|
||||
const [entry] = getRecentLogEntries();
|
||||
expect(entry.requestId).toBe('req-9');
|
||||
expect(entry.stage).toBe('intake');
|
||||
});
|
||||
|
||||
it('emits no requestId when called outside ALS', () => {
|
||||
const logger = createLogger('unit:stages');
|
||||
logger.info('plain.event', 'msg');
|
||||
const [entry] = getRecentLogEntries();
|
||||
expect(entry.requestId).toBeUndefined();
|
||||
expect(entry.stage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves existing logger.info/warn/error/debug methods', () => {
|
||||
const logger = createLogger('unit:stages');
|
||||
logger.info('e1', 'm1');
|
||||
logger.warn('e2', 'm2');
|
||||
logger.error('e3', 'm3');
|
||||
logger.debug('e4', 'm4');
|
||||
const entries = getRecentLogEntries();
|
||||
expect(entries.map((e) => e.level)).toEqual(['info', 'warn', 'error', 'debug']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { LogsEntry, LogsLevel } from '@/lib/api-client';
|
||||
|
||||
/**
|
||||
* Deterministic fixture for the redesigned logs surface.
|
||||
*
|
||||
* Mix:
|
||||
* - 30 traces of 3-6 stages each (~140 entries) sharing a `requestId`.
|
||||
* - ~60 standalone entries (no `requestId`) including pre-intake / system rows.
|
||||
* - All 4 levels present; partial traces (incomplete stage list) included.
|
||||
*
|
||||
* Used in dev mode via `?mock=logs` URL flag (see `use-logs.ts`). Tree-shaken
|
||||
* from production builds because the import site is guarded by
|
||||
* `import.meta.env.DEV`.
|
||||
*/
|
||||
|
||||
const STAGES = ['intake', 'auth', 'route', 'handler', 'provider', 'response'] as const;
|
||||
|
||||
const MODULES = [
|
||||
'api.gateway',
|
||||
'auth.oauth',
|
||||
'cliproxy.router',
|
||||
'profile.runtime',
|
||||
'config.loader',
|
||||
'doctor.healthcheck',
|
||||
'cli.exec',
|
||||
'dashboard.api',
|
||||
] as const;
|
||||
|
||||
const SOURCES = ['ccs-cli', 'cliproxy', 'dashboard'] as const;
|
||||
|
||||
const EVENTS = [
|
||||
'request.received',
|
||||
'request.dispatched',
|
||||
'provider.invoked',
|
||||
'provider.responded',
|
||||
'response.flushed',
|
||||
'profile.activated',
|
||||
'config.read',
|
||||
'token.refreshed',
|
||||
] as const;
|
||||
|
||||
// Deterministic PRNG so the fixture is stable across reloads.
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const rng = mulberry32(1138_1142);
|
||||
|
||||
function pick<T>(items: readonly T[]): T {
|
||||
return items[Math.floor(rng() * items.length)] as T;
|
||||
}
|
||||
|
||||
function pickLevel(weights = { debug: 0.5, info: 0.3, warn: 0.15, error: 0.05 }): LogsLevel {
|
||||
const r = rng();
|
||||
let acc = 0;
|
||||
for (const [level, w] of Object.entries(weights) as Array<[LogsLevel, number]>) {
|
||||
acc += w;
|
||||
if (r <= acc) return level;
|
||||
}
|
||||
return 'debug';
|
||||
}
|
||||
|
||||
const BASE_TS = Date.parse('2026-04-30T17:00:00Z');
|
||||
|
||||
let entryCounter = 0;
|
||||
function nextId(prefix: string): string {
|
||||
entryCounter += 1;
|
||||
return `${prefix}-${entryCounter.toString(36)}`;
|
||||
}
|
||||
|
||||
function buildTrace(traceIndex: number): LogsEntry[] {
|
||||
const requestId = `req_${(traceIndex + 1).toString().padStart(4, '0')}`;
|
||||
const traceStart = BASE_TS - traceIndex * 47_000 - Math.floor(rng() * 1500);
|
||||
const stageCount = 3 + Math.floor(rng() * 4); // 3..6
|
||||
const moduleName = pick(MODULES);
|
||||
const source = pick(SOURCES);
|
||||
const stages = STAGES.slice(0, stageCount);
|
||||
// ~12% of traces are partial -- drop a middle stage to simulate gaps
|
||||
const partial = rng() < 0.12 && stages.length > 3;
|
||||
const effectiveStages = partial
|
||||
? stages.filter((_, idx) => idx !== Math.floor(stages.length / 2))
|
||||
: stages;
|
||||
|
||||
let cursor = traceStart;
|
||||
return effectiveStages.map((stage, idx) => {
|
||||
const latencyMs = 5 + Math.floor(rng() * 220);
|
||||
cursor += latencyMs + Math.floor(rng() * 6);
|
||||
const level = idx === effectiveStages.length - 1 && rng() < 0.18 ? 'error' : pickLevel();
|
||||
const event = pick(EVENTS);
|
||||
const ts = new Date(cursor).toISOString();
|
||||
return {
|
||||
id: nextId('trace'),
|
||||
timestamp: ts,
|
||||
level,
|
||||
source,
|
||||
event,
|
||||
message: `${moduleName} ${stage} ${level === 'error' ? 'failed' : 'ok'}`,
|
||||
processId: 4000 + (traceIndex % 8),
|
||||
runId: `run_${(traceIndex % 12).toString().padStart(3, '0')}`,
|
||||
context: undefined,
|
||||
requestId,
|
||||
module: moduleName,
|
||||
stage,
|
||||
latencyMs,
|
||||
metadata: {
|
||||
attempt: idx + 1,
|
||||
traceIndex,
|
||||
cacheHit: rng() < 0.3,
|
||||
upstream: source,
|
||||
},
|
||||
error:
|
||||
level === 'error'
|
||||
? {
|
||||
message: `${moduleName}.${stage} timed out after ${latencyMs}ms`,
|
||||
code: 'ERR_TIMEOUT',
|
||||
stack: `at ${moduleName}.${stage}\n at handler.run`,
|
||||
}
|
||||
: undefined,
|
||||
} satisfies LogsEntry;
|
||||
});
|
||||
}
|
||||
|
||||
function buildStandalone(index: number): LogsEntry {
|
||||
const ts = new Date(BASE_TS - index * 11_000 - Math.floor(rng() * 4000)).toISOString();
|
||||
const level = pickLevel({ debug: 0.55, info: 0.3, warn: 0.1, error: 0.05 });
|
||||
const moduleName = pick(MODULES);
|
||||
const source = pick(SOURCES);
|
||||
const event = pick(EVENTS);
|
||||
return {
|
||||
id: nextId('solo'),
|
||||
timestamp: ts,
|
||||
level,
|
||||
source,
|
||||
event,
|
||||
message: `${moduleName} ${event.replace('.', ' ')}`,
|
||||
processId: 4000 + (index % 8),
|
||||
runId: null,
|
||||
context: undefined,
|
||||
requestId: undefined,
|
||||
module: moduleName,
|
||||
stage: undefined,
|
||||
latencyMs: rng() < 0.6 ? Math.floor(rng() * 80) : undefined,
|
||||
metadata: { standalone: true, source },
|
||||
error: undefined,
|
||||
} satisfies LogsEntry;
|
||||
}
|
||||
|
||||
const traces: LogsEntry[] = [];
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
traces.push(...buildTrace(i));
|
||||
}
|
||||
|
||||
const standalones: LogsEntry[] = [];
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
standalones.push(buildStandalone(i));
|
||||
}
|
||||
|
||||
export const STRUCTURED_LOG_ENTRIES: LogsEntry[] = [...traces, ...standalones].sort((a, b) =>
|
||||
b.timestamp.localeCompare(a.timestamp)
|
||||
);
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { LogsEntry, LogsLevel } from '@/lib/api-client';
|
||||
import type { TraceGroup } from './logs-trace-row';
|
||||
|
||||
const LEVEL_RANK: Record<LogsLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||
|
||||
export interface LeafItem {
|
||||
kind: 'leaf';
|
||||
entry: LogsEntry;
|
||||
/**
|
||||
* When >1, this leaf represents N consecutive identical entries that have
|
||||
* been coalesced. Row renderer shows a ×N badge so dashboard self-polling
|
||||
* floods don't drown real signal.
|
||||
*/
|
||||
repeatCount?: number;
|
||||
collapsedRange?: { fromTs: string; toTs: string };
|
||||
}
|
||||
|
||||
export type DerivedItem = LeafItem | TraceGroup;
|
||||
|
||||
/**
|
||||
* Tuple key for coalescing standalone leaves. Includes `message` and
|
||||
* `source` so two adjacent logs that share event/module/level but report
|
||||
* different content (or come from a different service) stay distinct
|
||||
* (e.g. `User logged in: alice` and `User logged in: bob`). Excludes
|
||||
* `latencyMs` and `metadata` because those drift per request even on
|
||||
* truly redundant polls — including them would prevent any coalescing.
|
||||
*
|
||||
* NB: this only applies to *leaves* (entries without `requestId`). Trace
|
||||
* children render uncoalesced so retries and duplicated-stage emissions
|
||||
* stay individually inspectable.
|
||||
*/
|
||||
function coalesceKey(entry: LogsEntry): string {
|
||||
return [
|
||||
entry.event ?? '',
|
||||
entry.message ?? '',
|
||||
entry.stage ?? '',
|
||||
entry.module ?? entry.source ?? '',
|
||||
entry.level,
|
||||
entry.requestId ?? '',
|
||||
entry.source ?? '',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* For an entry without an explicit `stage` field, derive a short chip label
|
||||
* from the event name so the trace timeline still renders meaningful badges
|
||||
* instead of empty pills. Last `.`-segment, capped at 12 chars.
|
||||
*/
|
||||
export function deriveStageHint(entry: LogsEntry): string | undefined {
|
||||
if (entry.stage && entry.stage.length > 0) return entry.stage;
|
||||
if (entry.event && entry.event.length > 0) {
|
||||
const last = entry.event.split('.').pop() ?? entry.event;
|
||||
return last.slice(0, 12);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure helper: derive a list of either standalone leaves or trace groups
|
||||
* (entries sharing a `requestId`).
|
||||
*
|
||||
* Single-pass, O(n). Walks `entries` in input order so leaf coalescing
|
||||
* sees the *real* adjacency: a leaf only extends the previous leaf when
|
||||
* no other entry (trace child included) appeared between them. This
|
||||
* preserves signal — two identical no-requestId entries split by an
|
||||
* unrelated trace stay as two separate rows, not a fake `×2`.
|
||||
*
|
||||
* Trace groups gather all children sharing a requestId regardless of
|
||||
* interleaving; they're sorted by `ts asc` before display, with the
|
||||
* group's positional `ts` set to the oldest child so it slots correctly
|
||||
* in the reverse-chronological display sort below. Children are NOT
|
||||
* coalesced — every stage is preserved for individual inspection.
|
||||
*/
|
||||
export function deriveTraceGroups(entries: LogsEntry[]): DerivedItem[] {
|
||||
const items: DerivedItem[] = [];
|
||||
const traceIndex = new Map<string, number>(); // requestId -> index in items
|
||||
let lastLeafIdx = -1;
|
||||
let lastLeafKey = '';
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.requestId) {
|
||||
// Trace child. Append to existing group or create one.
|
||||
const existingIdx = traceIndex.get(entry.requestId);
|
||||
if (existingIdx !== undefined) {
|
||||
(items[existingIdx] as TraceGroup).children.push(entry);
|
||||
} else {
|
||||
const grp: TraceGroup = {
|
||||
kind: 'trace',
|
||||
requestId: entry.requestId,
|
||||
module: entry.module ?? entry.source,
|
||||
source: entry.source,
|
||||
ts: entry.timestamp,
|
||||
maxLevel: entry.level,
|
||||
totalLatencyMs: 0,
|
||||
children: [entry],
|
||||
};
|
||||
items.push(grp);
|
||||
traceIndex.set(entry.requestId, items.length - 1);
|
||||
}
|
||||
// A trace entry breaks any leaf-run adjacency.
|
||||
lastLeafIdx = -1;
|
||||
lastLeafKey = '';
|
||||
} else {
|
||||
// Standalone leaf.
|
||||
const key = coalesceKey(entry);
|
||||
if (lastLeafIdx >= 0 && key === lastLeafKey) {
|
||||
const prev = items[lastLeafIdx] as LeafItem;
|
||||
prev.repeatCount = (prev.repeatCount ?? 1) + 1;
|
||||
prev.collapsedRange = {
|
||||
fromTs: prev.collapsedRange?.fromTs ?? prev.entry.timestamp,
|
||||
toTs: entry.timestamp,
|
||||
};
|
||||
} else {
|
||||
const leaf: LeafItem = { kind: 'leaf', entry };
|
||||
items.push(leaf);
|
||||
lastLeafIdx = items.length - 1;
|
||||
lastLeafKey = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize trace groups: sort children, compute aggregates, set group ts.
|
||||
for (const item of items) {
|
||||
if (item.kind === 'trace') {
|
||||
item.children.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||
let maxLevel: LogsLevel = 'debug';
|
||||
let total = 0;
|
||||
for (const c of item.children) {
|
||||
if (LEVEL_RANK[c.level] > LEVEL_RANK[maxLevel]) maxLevel = c.level;
|
||||
if (typeof c.latencyMs === 'number') total += c.latencyMs;
|
||||
}
|
||||
item.maxLevel = maxLevel;
|
||||
item.totalLatencyMs = total;
|
||||
const head = item.children[0];
|
||||
if (head) item.ts = head.timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// Display sort: newest first.
|
||||
return items.sort((a, b) => {
|
||||
const at = a.kind === 'trace' ? a.ts : a.entry.timestamp;
|
||||
const bt = b.kind === 'trace' ? b.ts : b.entry.timestamp;
|
||||
return bt.localeCompare(at);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Pause, Play, ArrowDownToLine } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FOCUS_RING } from './tokens';
|
||||
|
||||
export interface LiveTailControlsProps {
|
||||
isPaused: boolean;
|
||||
pendingCount: number;
|
||||
onTogglePause: () => void;
|
||||
onJumpToBottom?: () => void;
|
||||
}
|
||||
|
||||
export function LiveTailControls({
|
||||
isPaused,
|
||||
pendingCount,
|
||||
onTogglePause,
|
||||
onJumpToBottom,
|
||||
}: LiveTailControlsProps) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-pressed={isPaused}
|
||||
onClick={onTogglePause}
|
||||
className={cn('h-7 gap-1.5 px-2 text-xs font-medium', FOCUS_RING)}
|
||||
>
|
||||
{isPaused ? (
|
||||
<Play className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<Pause className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{isPaused ? 'Resume tail' : 'Pause tail'}
|
||||
</Button>
|
||||
{isPaused && pendingCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePause}
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
'inline-flex h-7 items-center gap-1.5 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 text-[11px] font-medium text-amber-700 dark:text-amber-300',
|
||||
FOCUS_RING
|
||||
)}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden="true" />
|
||||
{pendingCount} new {pendingCount === 1 ? 'entry' : 'entries'} · click to resume
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{onJumpToBottom ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onJumpToBottom}
|
||||
className={cn('h-7 gap-1.5 px-2 text-xs', FOCUS_RING)}
|
||||
>
|
||||
<ArrowDownToLine className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Jump to bottom
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,21 @@
|
||||
import type { LogsLevel } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getLevelLabel } from './utils';
|
||||
|
||||
const LEVEL_STYLES: Record<LogsLevel, string> = {
|
||||
error: 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300',
|
||||
warn: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300',
|
||||
info: 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300',
|
||||
debug: 'border-zinc-500/30 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300',
|
||||
};
|
||||
import { LEVEL_TOKENS } from './tokens';
|
||||
|
||||
export function LogLevelBadge({ level, className }: { level: LogsLevel; className?: string }) {
|
||||
const token = LEVEL_TOKENS[level];
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium uppercase tracking-[0.12em]',
|
||||
LEVEL_STYLES[level],
|
||||
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium',
|
||||
token.border,
|
||||
token.bg,
|
||||
token.fg,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full', token.dot)} aria-hidden="true" />
|
||||
{getLevelLabel(level)}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Save, Settings2, ShieldAlert, RotateCcw, Activity } from 'lucide-react';
|
||||
import { RotateCcw, Save } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -12,29 +12,25 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { LogsConfig, UpdateLogsConfigPayload } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
// TODO i18n: import { useTranslation } from 'react-i18next'; when keys are ready
|
||||
|
||||
function parseInteger(value: string, fallback: number) {
|
||||
function parseInteger(value: string, fallback: number): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (Number.isNaN(parsed)) return fallback;
|
||||
return Math.max(0, parsed);
|
||||
}
|
||||
|
||||
export function LogsConfigCard({
|
||||
config,
|
||||
onSave,
|
||||
isPending,
|
||||
}: {
|
||||
export interface LogsConfigCardProps {
|
||||
config: LogsConfig;
|
||||
onSave: (payload: UpdateLogsConfigPayload) => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
// TODO i18n: uncomment when keys for Commit Changes, Rollback Draft, etc. are added
|
||||
// const { t } = useTranslation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logging policy form. Calm chrome, designed to live inside a `Sheet`.
|
||||
* Renamed semantically to "form" -- the export name remains `LogsConfigCard`
|
||||
* for consumer compatibility.
|
||||
*/
|
||||
export function LogsConfigCard({ config, onSave, isPending }: LogsConfigCardProps) {
|
||||
const [draft, setDraft] = useState(config);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,244 +40,123 @@ export function LogsConfigCard({
|
||||
const isDirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(config), [config, draft]);
|
||||
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-2xl border-2 border-border/60 bg-card/40 p-1 shadow-lg transition-all hover:border-border">
|
||||
<div className="flex items-center justify-between border-b border-border bg-muted/30 px-5 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/5 border border-primary/20">
|
||||
<Settings2 className="h-3.5 w-3.5 text-primary" />
|
||||
</div>
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSave(draft);
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-4 rounded border border-border bg-background px-3 py-2">
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground">
|
||||
Logging Policy
|
||||
</h3>
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.1em] text-foreground/45">
|
||||
Retention and privacy
|
||||
<Label htmlFor="logs-enabled" className="text-sm font-medium">
|
||||
Enabled
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">Capture structured log entries.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="logs-enabled"
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(checked) => setDraft((c) => ({ ...c, enabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded border border-border bg-background px-3 py-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="logs-redact" className="text-sm font-medium">
|
||||
Redact sensitive values
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Hide payload values until explicitly revealed.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="logs-redact"
|
||||
checked={draft.redact}
|
||||
onCheckedChange={(checked) => setDraft((c) => ({ ...c, redact: checked }))}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 rounded-full',
|
||||
config.enabled ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' : 'bg-zinc-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 p-5">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-xl border border-border/40 bg-background/50 p-3 flex flex-col gap-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">
|
||||
Active Status
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[11px] font-semibold uppercase tracking-[0.1em]',
|
||||
config.enabled ? 'text-emerald-500' : 'text-zinc-500'
|
||||
)}
|
||||
>
|
||||
{config.enabled ? 'Live' : 'Off'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/40 bg-background/50 p-3 flex flex-col gap-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">
|
||||
Redaction
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[11px] font-semibold uppercase tracking-[0.1em]',
|
||||
config.redact ? 'text-primary' : 'text-muted-foreground/40'
|
||||
)}
|
||||
>
|
||||
{config.redact ? 'Enforced' : 'Plain'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="logs-config-level" className="text-sm font-medium">
|
||||
Minimum level
|
||||
</Label>
|
||||
<Select
|
||||
value={draft.level}
|
||||
onValueChange={(value) =>
|
||||
setDraft((c) => ({ ...c, level: value as LogsConfig['level'] }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="logs-config-level" className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="error">Error only</SelectItem>
|
||||
<SelectItem value="warn">Warn and above</SelectItem>
|
||||
<SelectItem value="info">Info and above</SelectItem>
|
||||
<SelectItem value="debug">Full debug</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-4 rounded-xl border border-border/40 bg-background/20 px-4 py-3 transition-colors hover:bg-background/40">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="logs-enabled"
|
||||
className="text-[12px] font-semibold uppercase tracking-[0.12em]"
|
||||
>
|
||||
Pipeline
|
||||
</Label>
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground/55">
|
||||
Enable structured logging
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="logs-enabled"
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setDraft((current) => ({ ...current, enabled: checked }))
|
||||
}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-xl border border-border/40 bg-background/20 px-4 py-3 transition-colors hover:bg-background/40">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="logs-redact"
|
||||
className="text-[12px] font-semibold uppercase tracking-[0.12em]"
|
||||
>
|
||||
Masking
|
||||
</Label>
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground/55">
|
||||
Sanitize payload data
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="logs-redact"
|
||||
checked={draft.redact}
|
||||
onCheckedChange={(checked) =>
|
||||
setDraft((current) => ({ ...current, redact: checked }))
|
||||
}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
<Label htmlFor="logs-rotate-mb" className="text-sm font-medium">
|
||||
Rotation (MB)
|
||||
</Label>
|
||||
<Input
|
||||
id="logs-rotate-mb"
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft.rotate_mb}
|
||||
onChange={(e) =>
|
||||
setDraft((c) => ({
|
||||
...c,
|
||||
rotate_mb: parseInteger(e.target.value, c.rotate_mb),
|
||||
}))
|
||||
}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<ShieldAlert className="h-3 w-3 text-primary/40" />
|
||||
<Label
|
||||
htmlFor="logs-config-level"
|
||||
className="text-[10px] font-semibold uppercase tracking-[0.12em] text-foreground/70"
|
||||
>
|
||||
Minimum Operational Threshold
|
||||
</Label>
|
||||
</div>
|
||||
<Select
|
||||
value={draft.level}
|
||||
onValueChange={(value) =>
|
||||
setDraft((current) => ({ ...current, level: value as LogsConfig['level'] }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="logs-config-level"
|
||||
className="h-10 rounded-xl border-2 border-border/40 bg-background/50 text-[12px] font-semibold uppercase tracking-[0.1em] focus:ring-0"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-2 border-border bg-card">
|
||||
<SelectItem
|
||||
value="error"
|
||||
className="text-[11px] font-semibold uppercase tracking-[0.1em]"
|
||||
>
|
||||
Error Only
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="warn"
|
||||
className="text-[11px] font-semibold uppercase tracking-[0.1em]"
|
||||
>
|
||||
Warn + Above
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="info"
|
||||
className="text-[11px] font-semibold uppercase tracking-[0.1em]"
|
||||
>
|
||||
Info + Above
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="debug"
|
||||
className="text-[11px] font-semibold uppercase tracking-[0.1em]"
|
||||
>
|
||||
Full Debug
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="logs-rotate-mb"
|
||||
className="px-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-foreground/50"
|
||||
>
|
||||
Rotation (MB)
|
||||
</Label>
|
||||
<Input
|
||||
id="logs-rotate-mb"
|
||||
type="number"
|
||||
min={1}
|
||||
className="h-10 rounded-xl border-2 border-border/40 bg-background/50 font-mono text-[12px] font-medium focus-visible:ring-0"
|
||||
value={draft.rotate_mb}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
rotate_mb: parseInteger(event.target.value, current.rotate_mb),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="logs-retain-days"
|
||||
className="px-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-foreground/50"
|
||||
>
|
||||
Retain (Days)
|
||||
</Label>
|
||||
<Input
|
||||
id="logs-retain-days"
|
||||
type="number"
|
||||
min={1}
|
||||
className="h-10 rounded-xl border-2 border-border/40 bg-background/50 font-mono text-[12px] font-medium focus-visible:ring-0"
|
||||
value={draft.retain_days}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
retain_days: parseInteger(event.target.value, current.retain_days),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-4 border-t border-border">
|
||||
<Button
|
||||
onClick={() => onSave(draft)}
|
||||
disabled={!isDirty || isPending}
|
||||
className="h-10 w-full gap-2 rounded-xl bg-primary text-[11px] font-semibold uppercase tracking-[0.14em] shadow-lg shadow-primary/20 transition-all hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{/* TODO i18n: missing key for "Commit Changes" */}
|
||||
Commit Changes
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setDraft(config)}
|
||||
disabled={!isDirty || isPending}
|
||||
className="h-9 gap-2 text-[10px] font-medium uppercase tracking-[0.12em] text-foreground/45 hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
{/* TODO i18n: missing key for "Rollback Draft" */}
|
||||
Rollback Draft
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="logs-retain-days" className="text-sm font-medium">
|
||||
Retention (days)
|
||||
</Label>
|
||||
<Input
|
||||
id="logs-retain-days"
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft.retain_days}
|
||||
onChange={(e) =>
|
||||
setDraft((c) => ({
|
||||
...c,
|
||||
retain_days: parseInteger(e.target.value, c.retain_days),
|
||||
}))
|
||||
}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between bg-muted/20 px-5 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Activity className="h-2.5 w-2.5 text-primary/40" />
|
||||
<span className="text-[9px] font-medium uppercase tracking-[0.12em] text-foreground/30">
|
||||
Operational Logic v3.4
|
||||
</span>
|
||||
</div>
|
||||
{isDirty && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-1 w-1 rounded-full bg-amber-500 animate-pulse" />
|
||||
<span className="text-[9px] font-medium uppercase tracking-[0.12em] text-amber-500/70">
|
||||
Pending
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 border-t border-border pt-3">
|
||||
<Button type="submit" disabled={!isDirty || isPending} size="sm" className="gap-1.5">
|
||||
<Save className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Save changes
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!isDirty || isPending}
|
||||
onClick={() => setDraft(config)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,271 +1,271 @@
|
||||
import {
|
||||
FileJson,
|
||||
Info,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
Fingerprint,
|
||||
Database,
|
||||
Cpu,
|
||||
Activity,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Copy, Eye, EyeOff, GitBranch } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import type { LogsEntry } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LogLevelBadge } from './log-level-badge';
|
||||
import { formatJson } from './utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LogsEmpty } from './logs-empty';
|
||||
import {
|
||||
formatJson,
|
||||
getDisplayLatency,
|
||||
getDisplayModule,
|
||||
getDisplayRequestId,
|
||||
getDisplayStage,
|
||||
} from './utils';
|
||||
import { FOCUS_RING, MONO_NUMERIC } from './tokens';
|
||||
|
||||
function MetaRow({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
}: {
|
||||
export interface LogsDetailPanelProps {
|
||||
entry: LogsEntry | null;
|
||||
sourceLabel?: string;
|
||||
/** Optional: when provided, "Show trace" button surfaces & calls this. */
|
||||
onShowTrace?: (requestId: string) => void;
|
||||
/** When true, redact metadata leaves until user reveals. */
|
||||
redact?: boolean;
|
||||
}
|
||||
|
||||
interface OverviewRow {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon?: LucideIcon;
|
||||
value: string | number | null | undefined;
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
function buildOverviewRows(entry: LogsEntry, sourceLabel?: string): OverviewRow[] {
|
||||
// Use shared accessors so this panel and the list row never diverge.
|
||||
return [
|
||||
{ label: 'Time', value: new Date(entry.timestamp).toISOString(), mono: true },
|
||||
{ label: 'Level', value: entry.level },
|
||||
{ label: 'Module', value: getDisplayModule(entry, sourceLabel) },
|
||||
{ label: 'Stage', value: getDisplayStage(entry) },
|
||||
{ label: 'Request ID', value: getDisplayRequestId(entry), mono: true },
|
||||
{ label: 'Latency', value: getDisplayLatency(entry), mono: true },
|
||||
{ label: 'Source', value: sourceLabel ?? entry.source ?? '—' },
|
||||
{ label: 'Run ID', value: entry.runId ?? '—', mono: true },
|
||||
{ label: 'Process ID', value: entry.processId ?? '—', mono: true },
|
||||
];
|
||||
}
|
||||
|
||||
function MetaTree({
|
||||
value,
|
||||
redact,
|
||||
depth = 0,
|
||||
}: {
|
||||
value: unknown;
|
||||
redact: boolean;
|
||||
depth?: number;
|
||||
}) {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
if (depth > 8) return <span className="text-muted-foreground">…</span>;
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return <span className="text-muted-foreground">null</span>;
|
||||
}
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
if (redact && typeof value === 'string' && value.length > 0 && !revealed) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground">redacted</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed(true)}
|
||||
aria-label="Reveal value"
|
||||
className={cn('rounded p-0.5 text-muted-foreground hover:text-foreground', FOCUS_RING)}
|
||||
>
|
||||
<Eye className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={cn('text-foreground/90', typeof value !== 'string' && MONO_NUMERIC)}>
|
||||
{String(value)}
|
||||
</span>
|
||||
{redact && typeof value === 'string' && revealed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed(false)}
|
||||
aria-label="Hide value"
|
||||
className={cn('rounded p-0.5 text-muted-foreground hover:text-foreground', FOCUS_RING)}
|
||||
>
|
||||
<EyeOff className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<ul className="border-l border-border/60 pl-3">
|
||||
{value.slice(0, 50).map((v, i) => (
|
||||
<li key={i} className="text-[12px]">
|
||||
<span className="text-muted-foreground">[{i}]</span>{' '}
|
||||
<MetaTree value={v} redact={redact} depth={depth + 1} />
|
||||
</li>
|
||||
))}
|
||||
{value.length > 50 ? (
|
||||
<li className="text-[12px] text-muted-foreground">… +{value.length - 50} more</li>
|
||||
) : null}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
// object
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj);
|
||||
return (
|
||||
<div className="group relative flex flex-col gap-1.5 rounded-xl border border-border/40 bg-background/40 p-3 transition-all hover:bg-background/80 hover:shadow-lg hover:shadow-black/5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && (
|
||||
<Icon className="h-3 w-3 text-primary/40 group-hover:text-primary transition-colors" />
|
||||
)}
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70 transition-colors group-hover:text-primary/60">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-1 w-1 rounded-full bg-border/40 group-hover:bg-primary/40 transition-colors" />
|
||||
</div>
|
||||
<p className="truncate font-mono text-[13px] font-medium tracking-tight text-foreground/85 transition-colors group-hover:text-foreground">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="border-l border-border/60 pl-3">
|
||||
{keys.map((k) => (
|
||||
<li key={k} className="text-[12px]">
|
||||
<span className="font-medium text-foreground/80">{k}</span>:{' '}
|
||||
<MetaTree value={obj[k]} redact={redact} depth={depth + 1} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
async function copyText(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
// ignore — copy is best effort
|
||||
}
|
||||
}
|
||||
|
||||
export function LogsDetailPanel({
|
||||
entry,
|
||||
sourceLabel,
|
||||
}: {
|
||||
entry: LogsEntry | null;
|
||||
sourceLabel?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
onShowTrace,
|
||||
redact = false,
|
||||
}: LogsDetailPanelProps) {
|
||||
const overviewRows = useMemo(
|
||||
() => (entry ? buildOverviewRows(entry, sourceLabel) : []),
|
||||
[entry, sourceLabel]
|
||||
);
|
||||
if (!entry) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-8 text-center animate-in fade-in duration-1000">
|
||||
<div className="relative mb-8">
|
||||
<div className="absolute inset-0 animate-ping rounded-full bg-primary/5 p-12" />
|
||||
<div className="relative rounded-full border-2 border-dashed border-border/40 p-10 bg-muted/5">
|
||||
<Terminal className="h-10 w-10 text-muted-foreground/20" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-xs space-y-3">
|
||||
<h3 className="text-[15px] font-semibold uppercase tracking-[0.14em] text-foreground/65">
|
||||
{/* TODO i18n: missing key for "Inspector Standby" */}
|
||||
Inspector Standby
|
||||
</h3>
|
||||
<p className="text-[13px] leading-relaxed text-muted-foreground/55 font-medium">
|
||||
Select a telemetry node from the active data queue to perform deep analysis of its
|
||||
operational context.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LogsEmpty variant="selection" />;
|
||||
}
|
||||
|
||||
const rawJson = formatJson(entry);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-card/30 backdrop-blur-sm animate-in fade-in slide-in-from-right-4 duration-500">
|
||||
{/* Tactical Inspector Header */}
|
||||
<div className="relative shrink-0 border-b border-border bg-card/60 p-6 shadow-sm overflow-hidden">
|
||||
{/* Pattern Overlay */}
|
||||
<div className="absolute inset-0 opacity-[0.03] pointer-events-none [background-image:radial-gradient(circle_at_center,var(--primary)_1px,transparent_0)] [background-size:16px_16px]" />
|
||||
|
||||
<div className="relative space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<LogLevelBadge level={entry.level} className="h-5 px-3 shadow-lg shadow-black/5" />
|
||||
<div className="h-4 w-px bg-border/60" />
|
||||
<div className="flex items-center gap-2 rounded-full border border-border bg-background/50 px-3 py-1 shadow-inner">
|
||||
<Database className="h-3 w-3 text-primary/60" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-foreground/75">
|
||||
{sourceLabel ?? entry.source}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-3 w-3 animate-pulse text-emerald-500" />
|
||||
<span className="font-mono text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground/45">
|
||||
{new Date(entry.timestamp).toLocaleTimeString(undefined, {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1 w-4 rounded-full bg-primary/40" />
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-primary/65">
|
||||
Event
|
||||
</p>
|
||||
</div>
|
||||
<h2 className="text-[24px] font-semibold tracking-tight text-foreground leading-tight break-words">
|
||||
{entry.event}
|
||||
</h2>
|
||||
<div className="rounded-xl border-l-4 border-primary/20 bg-muted/20 p-4 shadow-inner">
|
||||
<p className="text-[14px] font-medium leading-relaxed text-foreground/85 selection:bg-primary/20">
|
||||
{entry.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
<header className="flex shrink-0 flex-col gap-2 border-b border-border px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<LogLevelBadge level={entry.level} />
|
||||
<span className="truncate text-[12px] text-muted-foreground">{entry.event}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-6">
|
||||
<Tabs defaultValue="details" className="space-y-8">
|
||||
<TabsList className="grid h-auto w-full grid-cols-2 gap-1 rounded-xl border border-border/60 bg-muted/40 p-1">
|
||||
<TabsTrigger
|
||||
value="details"
|
||||
className="min-w-0 gap-2 rounded-lg px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.1em] transition-all data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-sm"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
{t('logsDetailPanel.details')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="raw"
|
||||
className="min-w-0 gap-2 rounded-lg px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.1em] transition-all data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-sm"
|
||||
>
|
||||
<FileJson className="h-3.5 w-3.5" />
|
||||
{/* TODO i18n: missing key for "Raw Context" */}
|
||||
Raw Context
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="details"
|
||||
className="mt-0 space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-500"
|
||||
<p className="truncate text-sm font-medium text-foreground">{entry.message}</p>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void copyText(rawJson)}
|
||||
className={cn('h-7 gap-1.5 px-2 text-xs', FOCUS_RING)}
|
||||
>
|
||||
<Copy className="h-3 w-3" aria-hidden="true" />
|
||||
Copy JSON
|
||||
</Button>
|
||||
{entry.requestId ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => entry.requestId && void copyText(entry.requestId)}
|
||||
className={cn('h-7 gap-1.5 px-2 text-xs', FOCUS_RING)}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<MetaRow
|
||||
label="Entry Signature"
|
||||
value={entry.id.slice(0, 16) + '...'}
|
||||
icon={Fingerprint}
|
||||
/>
|
||||
<MetaRow label="Telemetry Origin" value={entry.source} icon={Database} />
|
||||
<MetaRow label="Process ID" value={entry.processId ?? 'NA'} icon={Cpu} />
|
||||
<MetaRow
|
||||
label="Operational Run"
|
||||
value={entry.runId?.slice(0, 8) ?? 'NA'}
|
||||
icon={ShieldCheck}
|
||||
/>
|
||||
</div>
|
||||
<Copy className="h-3 w-3" aria-hidden="true" />
|
||||
Copy requestId
|
||||
</Button>
|
||||
) : null}
|
||||
{entry.requestId && onShowTrace ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => entry.requestId && onShowTrace(entry.requestId)}
|
||||
className={cn('h-7 gap-1.5 px-2 text-xs', FOCUS_RING)}
|
||||
>
|
||||
<GitBranch className="h-3 w-3" aria-hidden="true" />
|
||||
Show trace
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="relative overflow-hidden rounded-[2rem] border border-border bg-muted/10 p-1 shadow-inner group">
|
||||
{/* Background Scanline */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-primary/[0.03] to-transparent h-[200%] -top-full animate-[scan_8s_linear_infinite] pointer-events-none" />
|
||||
<Tabs defaultValue="overview" className="flex min-h-0 flex-1 flex-col">
|
||||
<TabsList className="m-2 h-8 w-fit bg-muted/40">
|
||||
<TabsTrigger value="overview" className="text-xs">
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="context" className="text-xs">
|
||||
Context
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="raw" className="text-xs">
|
||||
Raw
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="rounded-[calc(2rem-4px)] border border-dashed border-border/40 bg-background/50 p-6 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/5 border border-primary/20 text-primary shadow-inner">
|
||||
<Terminal className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-primary">
|
||||
Automated Summary
|
||||
</p>
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.1em] text-muted-foreground/55">
|
||||
Quick interpretation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[13px] leading-relaxed text-muted-foreground/80 font-medium">
|
||||
This telemetry node was captured from{' '}
|
||||
<span className="rounded bg-muted/40 px-1.5 py-0.5 font-semibold text-foreground">
|
||||
{sourceLabel ?? entry.source}
|
||||
</span>
|
||||
operating at the{' '}
|
||||
<span
|
||||
className={cn(
|
||||
'rounded px-1.5 py-0.5 text-[11px] font-semibold uppercase tracking-[0.08em]',
|
||||
entry.level === 'error'
|
||||
? 'bg-red-500/10 text-red-500'
|
||||
: entry.level === 'warn'
|
||||
? 'bg-amber-500/10 text-amber-500'
|
||||
: entry.level === 'info'
|
||||
? 'bg-sky-500/10 text-sky-500'
|
||||
: 'bg-zinc-500/10 text-zinc-500'
|
||||
)}
|
||||
>
|
||||
{entry.level}
|
||||
</span>{' '}
|
||||
threshold. The operational payload indicates an event state of{' '}
|
||||
<span className="font-semibold text-foreground">{entry.event}</span>.
|
||||
<TabsContent value="overview" className="m-0 min-h-0 flex-1">
|
||||
<ScrollArea className="h-full">
|
||||
<dl className="grid grid-cols-[120px_minmax(0,1fr)] gap-x-3 gap-y-2 px-4 pb-4">
|
||||
{overviewRows.map((row) => (
|
||||
<DetailRow key={row.label} {...row} />
|
||||
))}
|
||||
{entry.error ? (
|
||||
<div className="col-span-2 mt-2 rounded border border-red-500/30 bg-red-500/5 p-3">
|
||||
<p className="text-xs font-semibold text-red-700 dark:text-red-400">
|
||||
{entry.error.code ?? 'Error'}: {entry.error.message}
|
||||
</p>
|
||||
{entry.error.stack ? (
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap font-mono text-[11px] text-red-700/80 dark:text-red-300/80">
|
||||
{entry.error.stack}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</dl>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="raw"
|
||||
className="mt-0 animate-in fade-in slide-in-from-bottom-2 duration-500"
|
||||
<TabsContent value="context" className="m-0 min-h-0 flex-1">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="px-4 pb-4 text-[12px] leading-relaxed">
|
||||
{entry.metadata && Object.keys(entry.metadata).length > 0 ? (
|
||||
<MetaTree value={entry.metadata} redact={redact} />
|
||||
) : (
|
||||
<p className="text-muted-foreground">No structured metadata.</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="raw" className="m-0 min-h-0 flex-1">
|
||||
<ScrollArea className="h-full">
|
||||
<pre
|
||||
className={cn(
|
||||
'm-0 px-4 pb-4 text-[12px] leading-relaxed text-foreground/90',
|
||||
MONO_NUMERIC
|
||||
)}
|
||||
>
|
||||
<div className="group relative rounded-2xl border-2 border-border bg-zinc-950 p-1 shadow-2xl transition-all hover:border-primary/20">
|
||||
{/* Copy HUD */}
|
||||
<div className="absolute right-4 top-4 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-[9px] font-medium uppercase tracking-[0.12em] text-white/45 backdrop-blur-md">
|
||||
JSON.RAW.MODE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[30rem] w-full rounded-xl p-6">
|
||||
<pre className="font-mono text-[12px] leading-relaxed tracking-tight text-zinc-400 selection:bg-primary/40 selection:text-primary-foreground">
|
||||
{formatJson({
|
||||
id: entry.id,
|
||||
timestamp: entry.timestamp,
|
||||
level: entry.level,
|
||||
source: entry.source,
|
||||
event: entry.event,
|
||||
message: entry.message,
|
||||
processId: entry.processId,
|
||||
runId: entry.runId,
|
||||
context: entry.context ?? {},
|
||||
})}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-border bg-muted/5 px-6 py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" />
|
||||
<span className="text-[10px] font-medium uppercase tracking-[0.12em] text-foreground/35">
|
||||
Node Verified
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 w-px bg-border/40" />
|
||||
<span className="text-[10px] font-medium tabular-nums uppercase tracking-[0.12em] text-foreground/35">
|
||||
{entry.id.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/5 px-2 py-0.5 border border-primary/10">
|
||||
<span className="text-[9px] font-medium uppercase tracking-[0.12em] text-primary/65">
|
||||
CCS-TEC-v3
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{rawJson}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, mono }: OverviewRow) {
|
||||
return (
|
||||
<>
|
||||
<dt className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</dt>
|
||||
<dd
|
||||
className={cn('truncate text-[13px] text-foreground/90', mono && MONO_NUMERIC)}
|
||||
title={value === null || value === undefined ? undefined : String(value)}
|
||||
>
|
||||
{value ?? '—'}
|
||||
</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Inbox, FilterX, MousePointer, EyeOff } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export type LogsEmptyVariant = 'selection' | 'noData' | 'noResults' | 'selectionOutOfScope';
|
||||
|
||||
export interface LogsEmptyProps {
|
||||
variant: LogsEmptyVariant;
|
||||
onClearFilters?: () => void;
|
||||
}
|
||||
|
||||
const COPY: Record<LogsEmptyVariant, { title: string; body: string; icon: typeof Inbox }> = {
|
||||
selection: {
|
||||
title: 'No entry selected',
|
||||
body: 'Pick a row in the list to inspect its context. Use j/k to move and Enter to focus the detail.',
|
||||
icon: MousePointer,
|
||||
},
|
||||
noData: {
|
||||
title: 'No log activity yet',
|
||||
body: 'Once requests flow through the system, structured entries will appear here.',
|
||||
icon: Inbox,
|
||||
},
|
||||
noResults: {
|
||||
title: 'No entries match your filters',
|
||||
body: 'Adjust source, level, search, or time window to see more entries.',
|
||||
icon: FilterX,
|
||||
},
|
||||
selectionOutOfScope: {
|
||||
title: 'Selected entry not visible under current filter',
|
||||
body: 'Clear your filter or select another row to inspect details.',
|
||||
icon: EyeOff,
|
||||
},
|
||||
};
|
||||
|
||||
export function LogsEmpty({ variant, onClearFilters }: LogsEmptyProps) {
|
||||
const { title, body, icon: Icon } = COPY[variant];
|
||||
const showClear = variant === 'selectionOutOfScope' || variant === 'noResults';
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex h-full flex-col items-center justify-center gap-3 px-6 py-10 text-center"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-border bg-muted/30 text-muted-foreground">
|
||||
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
<p className="max-w-xs text-[13px] leading-relaxed text-muted-foreground">{body}</p>
|
||||
{showClear && onClearFilters ? (
|
||||
<Button variant="outline" size="sm" onClick={onClearFilters} className="mt-1">
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,36 @@
|
||||
import { Activity, ArrowRight, Inbox, Loader2 } from 'lucide-react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useCallback, useMemo, useState, type ReactNode } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import type { LogsEntry } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LogLevelBadge } from './log-level-badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LogsRow } from './logs-row';
|
||||
import { LogsTraceRow } from './logs-trace-row';
|
||||
import { type RowDensity } from './tokens';
|
||||
import { LogsEmpty } from './logs-empty';
|
||||
import { deriveTraceGroups, type DerivedItem } from './derive-trace-groups';
|
||||
|
||||
export interface LogsEntryListProps {
|
||||
entries: LogsEntry[];
|
||||
selectedEntryId: string | null;
|
||||
onSelect: (entryId: string) => void;
|
||||
sourceLabels: Record<string, string>;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
/**
|
||||
* Slot for live-tail controls (pause/resume + pending pill).
|
||||
* Phase-04 owns the controls; phase-03 just provides the slot.
|
||||
*/
|
||||
liveTailSlot?: ReactNode;
|
||||
/** Density toggle (phase-04 may surface a switch). Defaults to cozy. */
|
||||
density?: RowDensity;
|
||||
}
|
||||
|
||||
// Column layout shared by the header and every row variant:
|
||||
// chevron(16) · time(88) · level(64) · module(120) · message(flex) · latency(64) · request(100)
|
||||
// Stage chips render inline at the start of the MESSAGE column rather
|
||||
// than as a dedicated column. Fixed columns kept lean so MESSAGE has
|
||||
// breathing room at common saved-layout list-panel widths (~600px).
|
||||
const COLS_TEMPLATE =
|
||||
'grid grid-cols-[16px_88px_64px_120px_minmax(0,1fr)_64px_100px] items-center gap-3 px-3';
|
||||
|
||||
export function LogsEntryList({
|
||||
entries,
|
||||
@@ -11,218 +38,160 @@ export function LogsEntryList({
|
||||
onSelect,
|
||||
sourceLabels,
|
||||
isLoading,
|
||||
isFetching,
|
||||
}: {
|
||||
entries: LogsEntry[];
|
||||
selectedEntryId: string | null;
|
||||
onSelect: (entryId: string) => void;
|
||||
sourceLabels: Record<string, string>;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
isFetching: _isFetching,
|
||||
liveTailSlot,
|
||||
density = 'cozy',
|
||||
}: LogsEntryListProps) {
|
||||
const items = useMemo(() => deriveTraceGroups(entries), [entries]);
|
||||
// Two-set tristate: `userExpanded` = explicitly opened, `userCollapsed`
|
||||
// = explicitly closed. Auto-expand (when selection lives inside a trace)
|
||||
// only fires when the id is in neither set, so a user click on an
|
||||
// auto-expanded chevron actually collapses — previously it added to
|
||||
// `expanded` while auto-expand kept showing it, so no visible change.
|
||||
const [userExpanded, setUserExpanded] = useState<Set<string>>(new Set());
|
||||
const [userCollapsed, setUserCollapsed] = useState<Set<string>>(new Set());
|
||||
|
||||
// Compute the auto-expand id (if any) — derived inside toggle and the
|
||||
// effective set so a single source of truth drives both render and
|
||||
// click logic.
|
||||
const autoExpandedId = useMemo(() => {
|
||||
if (!selectedEntryId) return null;
|
||||
const owning = items.find(
|
||||
(it) => it.kind === 'trace' && it.children.some((c) => c.id === selectedEntryId)
|
||||
);
|
||||
return owning && owning.kind === 'trace' ? owning.requestId : null;
|
||||
}, [items, selectedEntryId]);
|
||||
|
||||
const toggle = useCallback(
|
||||
(requestId: string) => {
|
||||
const isAutoExpanded = autoExpandedId === requestId;
|
||||
const isExplicitlyOpen = userExpanded.has(requestId);
|
||||
const isExplicitlyClosed = userCollapsed.has(requestId);
|
||||
const isCurrentlyOpen = isExplicitlyOpen || (isAutoExpanded && !isExplicitlyClosed);
|
||||
|
||||
if (isCurrentlyOpen) {
|
||||
// Close: clear user-open, mark user-closed (beats auto-expand).
|
||||
setUserExpanded((prev) => {
|
||||
if (!prev.has(requestId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(requestId);
|
||||
return next;
|
||||
});
|
||||
setUserCollapsed((prev) => {
|
||||
if (prev.has(requestId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(requestId);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
// Open: clear user-closed, mark user-open.
|
||||
setUserExpanded((prev) => {
|
||||
if (prev.has(requestId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(requestId);
|
||||
return next;
|
||||
});
|
||||
setUserCollapsed((prev) => {
|
||||
if (!prev.has(requestId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(requestId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[autoExpandedId, userExpanded, userCollapsed]
|
||||
);
|
||||
|
||||
const effectiveExpanded = useMemo(() => {
|
||||
const result = new Set(userExpanded);
|
||||
if (autoExpandedId && !userCollapsed.has(autoExpandedId)) {
|
||||
result.add(autoExpandedId);
|
||||
}
|
||||
return result;
|
||||
}, [userExpanded, userCollapsed, autoExpandedId]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(_index: number, item: DerivedItem) => {
|
||||
if (item.kind === 'trace') {
|
||||
return (
|
||||
<LogsTraceRow
|
||||
group={item}
|
||||
isExpanded={effectiveExpanded.has(item.requestId)}
|
||||
selectedEntryId={selectedEntryId}
|
||||
density={density}
|
||||
sourceLabel={sourceLabels[item.source] ?? item.source}
|
||||
onToggle={toggle}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<LogsRow
|
||||
entry={item.entry}
|
||||
isSelected={item.entry.id === selectedEntryId}
|
||||
density={density}
|
||||
sourceLabel={sourceLabels[item.entry.source] ?? item.entry.source}
|
||||
onSelect={onSelect}
|
||||
repeatCount={item.repeatCount}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[density, effectiveExpanded, onSelect, selectedEntryId, sourceLabels, toggle]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background/50 backdrop-blur-sm">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card/40 px-6 py-3 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-primary shadow-[0_0_12px_rgba(var(--primary),0.6)]" />
|
||||
<h2 className="text-[12px] font-semibold uppercase tracking-[0.14em] text-foreground">
|
||||
Live Entry Stream
|
||||
</h2>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-border/60" />
|
||||
<div className="flex items-center gap-2 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5">
|
||||
<Activity className="h-3 w-3 text-emerald-500" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-emerald-600">
|
||||
Live telemetry
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isFetching && (
|
||||
<div className="flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-3 py-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-primary" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-primary">
|
||||
Syncing
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="font-mono text-[10px] font-medium uppercase tracking-[0.16em] text-foreground/35">
|
||||
NODE.01
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
||||
{/* Sticky column header outside Virtuoso scroll body */}
|
||||
<div
|
||||
role="row"
|
||||
className={cn(
|
||||
COLS_TEMPLATE,
|
||||
'h-9 shrink-0 border-b border-border bg-muted/30 text-[12px] font-medium uppercase tracking-wide text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<span role="columnheader" aria-hidden="true" />
|
||||
<span role="columnheader">Time</span>
|
||||
<span role="columnheader">Level</span>
|
||||
<span role="columnheader">Module</span>
|
||||
<span role="columnheader">Message</span>
|
||||
<span role="columnheader" className="text-right">
|
||||
Latency
|
||||
</span>
|
||||
<span role="columnheader" className="text-right">
|
||||
Request
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0 border-b border-border bg-muted/30 px-0 py-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-foreground/45">
|
||||
<div className="w-[6.5rem] shrink-0 px-6">{t('logsConfig.time')}</div>
|
||||
<div className="w-14 shrink-0 border-l border-border/10 px-2 text-center">
|
||||
{t('logsConfig.level')}
|
||||
{liveTailSlot ? (
|
||||
<div className="flex h-9 shrink-0 items-center justify-between border-b border-border bg-background px-3">
|
||||
{liveTailSlot}
|
||||
</div>
|
||||
<div className="w-[15rem] shrink-0 border-l border-border/10 px-4">
|
||||
{t('logsConfig.source')}
|
||||
</div>
|
||||
<div className="flex-1 border-l border-border/10 px-4">{t('logsConfig.message')}</div>
|
||||
<div className="w-[5.5rem] shrink-0 border-l border-border/10 px-2 text-center">
|
||||
{t('logsConfig.proc')}
|
||||
</div>
|
||||
<div className="w-[5.5rem] shrink-0 border-l border-border/10 px-3 text-center">
|
||||
{t('logsConfig.run')}
|
||||
</div>
|
||||
<div className="w-11 shrink-0 border-l border-border/10 px-2 text-center">
|
||||
{t('logsConfig.open')}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<div role="grid" className="min-h-0 flex-1">
|
||||
{isLoading ? (
|
||||
<div className="space-y-1 p-2">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].map((item) => (
|
||||
{Array.from({ length: 14 }).map((_, idx) => (
|
||||
<div
|
||||
key={item}
|
||||
className="h-10 w-full animate-pulse rounded-lg border border-border/5 bg-muted/20"
|
||||
key={idx}
|
||||
className="h-9 w-full animate-pulse rounded border border-border/40 bg-muted/30"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="flex h-full animate-in fade-in duration-1000 flex-col items-center justify-center gap-6 px-8 text-center">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 animate-ping rounded-full bg-muted/5 p-12" />
|
||||
<div className="relative rounded-full border border-dashed border-border/40 bg-muted/5 p-10">
|
||||
<Inbox className="h-10 w-10 text-muted-foreground/20" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-[13px] font-semibold uppercase tracking-[0.14em] text-foreground/55">
|
||||
No matching entries
|
||||
</p>
|
||||
<p className="max-w-[18rem] text-[12px] font-medium leading-relaxed text-muted-foreground/60">
|
||||
Your current source, level, or search filters are hiding the stream. Adjust them to
|
||||
bring entries back into view.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<LogsEmpty variant="noResults" />
|
||||
) : (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex flex-col">
|
||||
{entries.map((entry) => {
|
||||
const isSelected = entry.id === selectedEntryId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(entry.id)}
|
||||
className={cn(
|
||||
'group relative flex w-full items-center border-b border-border/5 px-0 py-2.5 text-left transition-all duration-150',
|
||||
isSelected
|
||||
? 'z-10 bg-primary/[0.08] shadow-[inset_4px_0_0_rgba(var(--primary),1)]'
|
||||
: 'bg-transparent hover:bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-y-0 left-0 w-1 origin-center scale-y-0 bg-primary transition-transform duration-300 group-hover:scale-y-100" />
|
||||
|
||||
<div className="flex w-full items-center gap-0">
|
||||
<div className="flex w-[6.5rem] shrink-0 items-center">
|
||||
<p
|
||||
className={cn(
|
||||
'px-6 font-mono text-[11px] font-semibold tabular-nums transition-colors',
|
||||
isSelected
|
||||
? 'text-primary'
|
||||
: 'text-foreground/60 group-hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{new Date(entry.timestamp).toLocaleTimeString(undefined, {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center gap-0">
|
||||
<div className="flex w-14 shrink-0 items-center justify-center opacity-80 transition-opacity group-hover:opacity-100">
|
||||
<LogLevelBadge
|
||||
level={entry.level}
|
||||
className="origin-center scale-[0.85]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-[15rem] shrink-0 flex-col gap-0.5 overflow-hidden px-4">
|
||||
<span
|
||||
className={cn(
|
||||
'truncate text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors',
|
||||
isSelected
|
||||
? 'text-foreground'
|
||||
: 'text-foreground/50 group-hover:text-foreground/80'
|
||||
)}
|
||||
>
|
||||
{sourceLabels[entry.source] ?? entry.source}
|
||||
</span>
|
||||
<span className="truncate text-[10px] font-medium uppercase tracking-[0.1em] text-muted-foreground/55">
|
||||
{entry.event}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 px-4">
|
||||
<p
|
||||
className={cn(
|
||||
'truncate text-[13px] font-medium leading-5 transition-colors',
|
||||
isSelected
|
||||
? 'text-foreground'
|
||||
: 'text-foreground/70 group-hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{entry.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-[5.5rem] shrink-0 items-center justify-center px-2 font-mono text-[10px] font-semibold tabular-nums tracking-[0.12em] text-foreground/30 transition-colors group-hover:text-primary/45">
|
||||
{entry.processId ?? '????'}
|
||||
</div>
|
||||
<div className="flex w-[5.5rem] shrink-0 items-center justify-center px-3 font-mono text-[10px] font-semibold tabular-nums tracking-[0.12em] text-foreground/30 transition-colors group-hover:text-primary/45">
|
||||
{entry.runId?.slice(0, 4).toUpperCase() ?? 'NONE'}
|
||||
</div>
|
||||
<div className="flex w-11 shrink-0 items-center justify-center px-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded-full border transition-all',
|
||||
isSelected
|
||||
? 'animate-in zoom-in duration-300 border-primary/20 bg-primary/10 text-primary'
|
||||
: 'border-transparent text-foreground/20 group-hover:border-border/40 group-hover:bg-background/80 group-hover:text-foreground/55'
|
||||
)}
|
||||
>
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<Virtuoso
|
||||
data={items}
|
||||
itemContent={renderItem}
|
||||
increaseViewportBy={{ top: 200, bottom: 400 }}
|
||||
followOutput={(atBottom) => (atBottom ? 'auto' : false)}
|
||||
computeItemKey={(_idx, item) =>
|
||||
item.kind === 'trace' ? `t:${item.requestId}` : `l:${item.entry.id}`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-border bg-muted/5 px-6 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] font-medium uppercase tracking-[0.12em] text-foreground/35">
|
||||
Node: CCS-CORE
|
||||
</span>
|
||||
<div className="h-1 w-1 rounded-full bg-border/40" />
|
||||
<span className="text-[10px] font-medium uppercase tracking-[0.12em] text-foreground/35">
|
||||
Status: Operational
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-medium uppercase tracking-[0.12em] text-foreground/35">
|
||||
Entries: {entries.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { AlertTriangle, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export interface LogsErrorProps {
|
||||
error: Error | { message?: string } | null;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export function LogsError({ error, onRetry }: LogsErrorProps) {
|
||||
const message = error?.message ?? 'Unknown error fetching logs.';
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex h-full flex-col items-center justify-center gap-3 px-6 py-10 text-center"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400">
|
||||
<AlertTriangle className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">Could not load logs</h3>
|
||||
<p className="max-w-sm text-[13px] leading-relaxed text-muted-foreground">{message}</p>
|
||||
<Button variant="outline" size="sm" onClick={onRetry} className="gap-2">
|
||||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,28 @@
|
||||
import { Search, RefreshCw, Filter, Shield, Zap } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, Search, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { LogsSource } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { LogsLevelFilter, LogsSourceFilter } from '@/hooks/use-logs';
|
||||
import { getLogLevelOptions } from '@/hooks/use-logs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
getLogLevelOptions,
|
||||
getLogsTimeWindowOptions,
|
||||
type LogsLevelFilter,
|
||||
type LogsSourceFilter,
|
||||
type LogsTimeWindow,
|
||||
} from '@/hooks/use-logs';
|
||||
import { FOCUS_RING } from './tokens';
|
||||
|
||||
export function LogsFilters({
|
||||
sources,
|
||||
selectedSource,
|
||||
onSourceChange,
|
||||
selectedLevel,
|
||||
onLevelChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
limit,
|
||||
onLimitChange,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
}: {
|
||||
export interface LogsFiltersProps {
|
||||
sources: LogsSource[];
|
||||
selectedSource: LogsSourceFilter;
|
||||
onSourceChange: (value: LogsSourceFilter) => void;
|
||||
@@ -32,169 +34,355 @@ export function LogsFilters({
|
||||
onLimitChange: (value: number) => void;
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
/** Phase-04 advanced filters (optional for back-compat). */
|
||||
moduleFilter?: string;
|
||||
onModuleChange?: (v: string) => void;
|
||||
stageFilter?: string;
|
||||
onStageChange?: (v: string) => void;
|
||||
requestIdFilter?: string;
|
||||
onRequestIdChange?: (v: string) => void;
|
||||
timeWindow?: LogsTimeWindow;
|
||||
onTimeWindowChange?: (v: LogsTimeWindow) => void;
|
||||
/** When true, hides entries from `web-server:*` sources. Default ON. */
|
||||
hideDashboardInternals?: boolean;
|
||||
onHideDashboardInternalsChange?: (next: boolean) => void;
|
||||
onClearAll?: () => void;
|
||||
}
|
||||
|
||||
interface ChipProps {
|
||||
label: string;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function Chip({ label, onRemove }: ChipProps) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-muted/40 px-2 py-0.5 text-[11px] text-foreground/80">
|
||||
{label}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label={`Remove ${label}`}
|
||||
className={cn('rounded-full p-0.5 hover:bg-muted', FOCUS_RING)}
|
||||
>
|
||||
<X className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogsFilters({
|
||||
sources,
|
||||
selectedSource,
|
||||
onSourceChange,
|
||||
selectedLevel,
|
||||
onLevelChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
limit,
|
||||
onLimitChange,
|
||||
onRefresh: _onRefresh,
|
||||
isRefreshing: _isRefreshing,
|
||||
moduleFilter = '',
|
||||
onModuleChange,
|
||||
stageFilter = '',
|
||||
onStageChange,
|
||||
requestIdFilter = '',
|
||||
onRequestIdChange,
|
||||
timeWindow = 'all',
|
||||
onTimeWindowChange,
|
||||
hideDashboardInternals = true,
|
||||
onHideDashboardInternalsChange,
|
||||
onClearAll,
|
||||
}: LogsFiltersProps) {
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const levels = getLogLevelOptions();
|
||||
const limits = [50, 100, 150, 250];
|
||||
const timeWindows = getLogsTimeWindowOptions();
|
||||
|
||||
const activeChips: Array<{ key: string; label: string; clear: () => void }> = [];
|
||||
if (selectedLevel !== 'all') {
|
||||
activeChips.push({
|
||||
key: 'level',
|
||||
label: `level: ${selectedLevel}`,
|
||||
clear: () => onLevelChange('all'),
|
||||
});
|
||||
}
|
||||
if (selectedSource !== 'all') {
|
||||
const label = sources.find((s) => s.source === selectedSource)?.label ?? selectedSource;
|
||||
activeChips.push({
|
||||
key: 'source',
|
||||
label: `source: ${label}`,
|
||||
clear: () => onSourceChange('all'),
|
||||
});
|
||||
}
|
||||
if (search.trim()) {
|
||||
activeChips.push({
|
||||
key: 'search',
|
||||
label: `search: ${search}`,
|
||||
clear: () => onSearchChange(''),
|
||||
});
|
||||
}
|
||||
if (moduleFilter) {
|
||||
activeChips.push({
|
||||
key: 'module',
|
||||
label: `module: ${moduleFilter}`,
|
||||
clear: () => onModuleChange?.(''),
|
||||
});
|
||||
}
|
||||
if (stageFilter) {
|
||||
activeChips.push({
|
||||
key: 'stage',
|
||||
label: `stage: ${stageFilter}`,
|
||||
clear: () => onStageChange?.(''),
|
||||
});
|
||||
}
|
||||
if (requestIdFilter) {
|
||||
activeChips.push({
|
||||
key: 'requestId',
|
||||
label: `requestId: ${requestIdFilter}`,
|
||||
clear: () => onRequestIdChange?.(''),
|
||||
});
|
||||
}
|
||||
if (timeWindow !== 'all') {
|
||||
activeChips.push({
|
||||
key: 'timeWindow',
|
||||
label: `time: ${timeWindow}`,
|
||||
clear: () => onTimeWindowChange?.('all'),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-in fade-in slide-in-from-left-2 duration-700">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary shadow-[0_0_8px_rgba(var(--primary),0.5)]" />
|
||||
<Label
|
||||
htmlFor="logs-search"
|
||||
className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/80"
|
||||
>
|
||||
Payload Search
|
||||
</Label>
|
||||
</div>
|
||||
<Zap className="h-3 w-3 text-primary/20" />
|
||||
</div>
|
||||
<div className="group relative">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-3.5 flex items-center transition-all group-focus-within:translate-x-1">
|
||||
<Search className="h-3.5 w-3.5 text-foreground/20 group-focus-within:text-primary transition-colors" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Primary row */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="logs-search"
|
||||
className="text-[11px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Search
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
id="logs-search"
|
||||
aria-label="Search"
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Scan for patterns..."
|
||||
className="h-11 rounded-xl border-2 border-border/40 bg-background/50 pl-10 text-[13px] font-medium text-foreground placeholder:text-foreground/35 focus-visible:border-primary/40 focus-visible:ring-0 transition-all shadow-inner"
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search message, event, module"
|
||||
className="h-9 pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Filter className="h-3 w-3 text-primary/40" />
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/80">
|
||||
Source Matrix
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1" aria-label="Source filter">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSourceChange('all')}
|
||||
className={cn(
|
||||
'group relative flex items-center justify-between rounded-lg border px-3 py-2 transition-all active:scale-[0.98]',
|
||||
selectedSource === 'all'
|
||||
? 'border-primary/50 bg-primary/10 text-primary shadow-[0_0_15px_rgba(var(--primary),0.1)]'
|
||||
: 'border-border/40 bg-muted/20 text-foreground/40 hover:border-border hover:bg-muted/40 hover:text-foreground/80'
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="logs-level"
|
||||
className="text-[11px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||
Global Stream
|
||||
</span>
|
||||
{selectedSource === 'all' && (
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary shadow-[0_0_8px_rgba(var(--primary),0.8)]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-1 mt-1">
|
||||
{sources.map((source) => (
|
||||
<button
|
||||
key={source.source}
|
||||
type="button"
|
||||
onClick={() => onSourceChange(source.source)}
|
||||
className={cn(
|
||||
'rounded-lg border px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-[0.1em] transition-all active:scale-[0.97]',
|
||||
selectedSource === source.source
|
||||
? 'border-primary/50 bg-primary/10 text-primary shadow-sm'
|
||||
: 'border-border/40 bg-muted/20 text-foreground/40 hover:border-border hover:bg-muted/40 hover:text-foreground/80'
|
||||
)}
|
||||
>
|
||||
{source.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
Level
|
||||
</Label>
|
||||
<Select value={selectedLevel} onValueChange={(v) => onLevelChange(v as LogsLevelFilter)}>
|
||||
<SelectTrigger id="logs-level" className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{levels.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="logs-source"
|
||||
className="text-[11px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Source
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedSource}
|
||||
onValueChange={(v) => onSourceChange(v as LogsSourceFilter)}
|
||||
>
|
||||
<SelectTrigger id="logs-source" className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All sources</SelectItem>
|
||||
{sources.map((s) => (
|
||||
<SelectItem key={s.source} value={s.source}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Threshold Control */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Shield className="h-3 w-3 text-primary/40" />
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/80">
|
||||
Sensitivity
|
||||
</Label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5" aria-label="Level filter">
|
||||
{levels.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onLevelChange(option.value)}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 rounded-lg border px-2 py-2 text-[10px] font-semibold uppercase tracking-[0.1em] transition-all active:scale-[0.97]',
|
||||
selectedLevel === option.value
|
||||
? 'border-primary/50 bg-primary/10 text-primary shadow-[0_0_15px_rgba(var(--primary),0.1)]'
|
||||
: 'border-border/40 bg-muted/20 text-foreground/40 hover:border-border hover:bg-muted/40 hover:text-foreground/80'
|
||||
)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<div
|
||||
className={cn(
|
||||
'h-0.5 w-4 rounded-full transition-colors',
|
||||
selectedLevel === option.value ? 'bg-primary' : 'bg-foreground/10'
|
||||
)}
|
||||
{/* Advanced */}
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn('h-7 w-full justify-between px-2 text-xs font-medium', FOCUS_RING)}
|
||||
aria-expanded={advancedOpen}
|
||||
>
|
||||
Advanced filters
|
||||
<ChevronDown
|
||||
className={cn('h-3.5 w-3.5 transition-transform', advancedOpen && 'rotate-180')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-2">
|
||||
{onModuleChange ? (
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="logs-module"
|
||||
className="text-[11px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Module
|
||||
</Label>
|
||||
<Input
|
||||
id="logs-module"
|
||||
value={moduleFilter}
|
||||
onChange={(e) => onModuleChange(e.target.value)}
|
||||
placeholder="e.g. cliproxy.router"
|
||||
className="h-9"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{onStageChange ? (
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="logs-stage"
|
||||
className="text-[11px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Stage
|
||||
</Label>
|
||||
<Input
|
||||
id="logs-stage"
|
||||
value={stageFilter}
|
||||
onChange={(e) => onStageChange(e.target.value)}
|
||||
placeholder="e.g. handler"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{onRequestIdChange ? (
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="logs-request-id"
|
||||
className="text-[11px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Request ID
|
||||
</Label>
|
||||
<Input
|
||||
id="logs-request-id"
|
||||
value={requestIdFilter}
|
||||
onChange={(e) => onRequestIdChange(e.target.value)}
|
||||
placeholder="req_…"
|
||||
className="h-9 font-mono"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{onTimeWindowChange ? (
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="logs-time-window"
|
||||
className="text-[11px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Time window
|
||||
</Label>
|
||||
<Select
|
||||
value={timeWindow}
|
||||
onValueChange={(v) => onTimeWindowChange(v as LogsTimeWindow)}
|
||||
>
|
||||
<SelectTrigger id="logs-time-window" className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{timeWindows.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
{onHideDashboardInternalsChange ? (
|
||||
<div className="flex items-start justify-between gap-3 rounded border border-border/60 bg-muted/20 p-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="logs-hide-internals"
|
||||
className="block text-[12px] font-medium text-foreground"
|
||||
>
|
||||
Hide dashboard internals
|
||||
</Label>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Suppress <code className="rounded bg-background px-1">web-server:*</code>{' '}
|
||||
self-polling.
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
id="logs-hide-internals"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
checked={hideDashboardInternals}
|
||||
onChange={(e) => onHideDashboardInternalsChange(e.target.checked)}
|
||||
className={cn('mt-0.5 h-4 w-4 cursor-pointer accent-foreground', FOCUS_RING)}
|
||||
aria-label="Hide dashboard internals"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
Visible entries
|
||||
</Label>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{limits.map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
type="button"
|
||||
variant={limit === option ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => onLimitChange(option)}
|
||||
>
|
||||
{option}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Operational Deck */}
|
||||
<div className="mt-4 rounded-2xl border-2 border-border bg-card/40 p-1.5 shadow-xl shadow-black/5">
|
||||
<div className="rounded-[calc(1rem-2px)] border border-dashed border-border bg-background/60 p-4 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-primary">
|
||||
Operational Window
|
||||
</p>
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.1em] text-foreground/45">
|
||||
Tail Capacity
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 rounded-lg bg-primary/5 border border-primary/10 flex items-center justify-center">
|
||||
<Zap className="h-4 w-4 text-primary/40" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-1.5" aria-label="Visible entries">
|
||||
{limits.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => onLimitChange(option)}
|
||||
className={cn(
|
||||
'rounded-md border py-1.5 text-[11px] font-semibold tabular-nums transition-all active:scale-[0.95]',
|
||||
limit === option
|
||||
? 'border-foreground bg-foreground text-background'
|
||||
: 'border-border/60 bg-muted/40 text-foreground/40 hover:bg-muted hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="group relative h-10 w-full overflow-hidden rounded-xl border-none bg-primary text-[11px] font-semibold uppercase tracking-[0.14em] text-primary-foreground transition-all hover:scale-[1.02] hover:shadow-lg hover:shadow-primary/20 active:scale-[0.98]"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
|
||||
<span>{t('logsConfig.refreshEntries')}</span>
|
||||
</div>
|
||||
</Button>
|
||||
{activeChips.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 border-t border-border pt-3">
|
||||
<span className="text-[11px] uppercase tracking-wide text-muted-foreground">Active:</span>
|
||||
{activeChips.map((c) => (
|
||||
<Chip key={c.key} label={c.label} onRemove={c.clear} />
|
||||
))}
|
||||
{onClearAll ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearAll}
|
||||
className="h-7 px-2 text-[11px] text-muted-foreground"
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { RefreshCw, Settings, ScrollText, HelpCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FOCUS_RING, MONO_NUMERIC } from './tokens';
|
||||
|
||||
export interface LogsHeaderStats {
|
||||
entries: number;
|
||||
traces: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface LogsHeaderProps {
|
||||
isFetching: boolean;
|
||||
hasError: boolean;
|
||||
capturedCount: number;
|
||||
stats?: LogsHeaderStats;
|
||||
onRefresh: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenShortcuts: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs page header.
|
||||
*
|
||||
* Two rows:
|
||||
* 1. Title bar (`LOGS.STREAM` ornamental marker matching dashboard's
|
||||
* `HEALTH.X` design language) + status pill + actions.
|
||||
* 2. Stat strip — entries / traces / errors counters mirroring the home
|
||||
* page's `LIVE Account Monitor` card grid.
|
||||
*
|
||||
* Visual unity is the goal here, not minimalism for its own sake.
|
||||
*/
|
||||
export function LogsHeader({
|
||||
isFetching,
|
||||
hasError,
|
||||
capturedCount,
|
||||
stats,
|
||||
onRefresh,
|
||||
onOpenSettings,
|
||||
onOpenShortcuts,
|
||||
}: LogsHeaderProps) {
|
||||
const status = hasError ? 'error' : isFetching ? 'syncing' : 'live';
|
||||
const statusLabel =
|
||||
status === 'error' ? 'Disconnected' : status === 'syncing' ? 'Syncing' : 'Live';
|
||||
const dotClass =
|
||||
status === 'error' ? 'bg-red-500' : status === 'syncing' ? 'bg-amber-500' : 'bg-emerald-500';
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-border bg-background">
|
||||
<div className="flex h-14 items-center justify-between gap-4 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<ScrollText className="h-4 w-4 text-foreground/70" aria-hidden="true" />
|
||||
<span className="text-[11px] font-mono uppercase tracking-[0.18em] text-muted-foreground">
|
||||
LOGS.STREAM
|
||||
</span>
|
||||
<h1 className="text-base font-semibold tracking-tight text-foreground">Live activity</h1>
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="ml-2 inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[12px] font-medium text-foreground/80"
|
||||
>
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full', dotClass)} aria-hidden="true" />
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
aria-label="Refresh logs"
|
||||
className={cn('h-8 gap-2 px-2.5 text-xs font-medium', FOCUS_RING)}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn('h-3.5 w-3.5', isFetching && 'animate-spin')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onOpenShortcuts}
|
||||
aria-label="Show keyboard shortcuts"
|
||||
title="Keyboard shortcuts (?)"
|
||||
className={cn('h-8 w-8', FOCUS_RING)}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onOpenSettings}
|
||||
aria-label="Open logging settings"
|
||||
className={cn('h-8 w-8', FOCUS_RING)}
|
||||
>
|
||||
<Settings className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat strip — visual unity with home page's `LIVE Account Monitor`. */}
|
||||
<div className="flex items-center gap-6 border-t border-border/60 bg-muted/20 px-4 py-2">
|
||||
<Stat label="Entries" value={capturedCount} fallback={`${capturedCount}`} />
|
||||
{stats ? (
|
||||
<>
|
||||
<Stat label="Traces" value={stats.traces} fallback={`${stats.traces}`} />
|
||||
<Stat
|
||||
label="Errors"
|
||||
value={stats.errors}
|
||||
fallback={`${stats.errors}`}
|
||||
tone={stats.errors > 0 ? 'error' : 'neutral'}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatProps {
|
||||
label: string;
|
||||
value: number;
|
||||
fallback: string;
|
||||
tone?: 'neutral' | 'error';
|
||||
}
|
||||
|
||||
function Stat({ label, value, fallback, tone = 'neutral' }: StatProps) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[10px] font-mono uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[14px] font-semibold tabular-nums',
|
||||
tone === 'error' ? 'text-red-600 dark:text-red-400' : 'text-foreground',
|
||||
MONO_NUMERIC
|
||||
)}
|
||||
title={String(value)}
|
||||
>
|
||||
{fallback}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Activity, Archive, Database, RadioTower } from 'lucide-react';
|
||||
import type { LogsConfig, LogsEntry, LogsSource } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatCount, formatLogTimestamp, formatRelativeLogTime } from './utils';
|
||||
// TODO i18n: import { useTranslation } from 'react-i18next'; when keys are ready
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
icon: Icon,
|
||||
accent,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
icon: typeof Activity;
|
||||
accent: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card/80 p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{label}</p>
|
||||
<p className="text-2xl font-semibold tracking-tight">{value}</p>
|
||||
<p className="text-sm text-muted-foreground">{detail}</p>
|
||||
</div>
|
||||
<div className={cn('rounded-xl p-2.5', accent)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogsOverviewCards({
|
||||
config,
|
||||
sources,
|
||||
entries,
|
||||
latestTimestamp,
|
||||
}: {
|
||||
config: LogsConfig;
|
||||
sources: LogsSource[];
|
||||
entries: LogsEntry[];
|
||||
latestTimestamp: string | null;
|
||||
}) {
|
||||
// TODO i18n: uncomment when keys for Pipeline/Retention/Coverage/Visible Entries are added
|
||||
// const { t } = useTranslation();
|
||||
const nativeSources = sources.filter((source) => source.kind === 'native').length;
|
||||
const legacySources = sources.length - nativeSources;
|
||||
const errorCount = entries.filter((entry) => entry.level === 'error').length;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{/* TODO i18n: missing keys for Pipeline/Retention/Coverage/Visible Entries labels and detail strings */}
|
||||
<MetricCard
|
||||
label="Pipeline"
|
||||
value={config.enabled ? 'Enabled' : 'Disabled'}
|
||||
detail={`Threshold: ${config.level.toUpperCase()} • Redaction ${config.redact ? 'on' : 'off'}`}
|
||||
icon={RadioTower}
|
||||
accent={
|
||||
config.enabled
|
||||
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
||||
: 'bg-zinc-500/10 text-zinc-700 dark:text-zinc-300'
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Retention"
|
||||
value={`${config.retain_days}d`}
|
||||
detail={`Rotate at ${config.rotate_mb} MB per file`}
|
||||
icon={Archive}
|
||||
accent="bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Coverage"
|
||||
value={formatCount(sources.length)}
|
||||
detail={`${nativeSources} active sources${legacySources > 0 ? ` • ${legacySources} legacy` : ''}`}
|
||||
icon={Database}
|
||||
accent="bg-sky-500/10 text-sky-700 dark:text-sky-300"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Visible Entries"
|
||||
value={formatCount(entries.length)}
|
||||
detail={`${formatCount(errorCount)} errors • ${formatRelativeLogTime(latestTimestamp)}`}
|
||||
icon={Activity}
|
||||
accent="bg-violet-500/10 text-violet-700 dark:text-violet-300"
|
||||
/>
|
||||
<div className="md:col-span-2 xl:col-span-4 rounded-2xl border border-border/70 bg-card/70 px-4 py-3 text-sm text-muted-foreground shadow-sm">
|
||||
Last ingested event:{' '}
|
||||
<span className="font-medium text-foreground">{formatLogTimestamp(latestTimestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,45 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
/**
|
||||
* Logs surface initial-load skeleton.
|
||||
* Shape mirrors the new shell: 48px header, 40px tab bar, 3-pane body.
|
||||
* `aria-busy` lives here; phase-06 layers full a11y polish.
|
||||
*/
|
||||
export function LogsPageSkeleton() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-6" aria-label={t('logsPageSkeleton.loadingLogs')}>
|
||||
<Card className="gap-4">
|
||||
<CardHeader className="space-y-3">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-8 w-72" />
|
||||
<Skeleton className="h-4 w-[30rem]" />
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
<Card key={item} className="gap-3">
|
||||
<CardHeader className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-7 w-24" />
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
<div
|
||||
className="flex h-full min-h-0 flex-col bg-background"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
aria-label={t('logsPageSkeleton.loadingLogs')}
|
||||
>
|
||||
<div className="flex h-12 items-center justify-between border-b border-border px-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-6 w-28" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_22rem]">
|
||||
<Card className="gap-4">
|
||||
<CardHeader className="space-y-3">
|
||||
<Skeleton className="h-4 w-52" />
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
<Skeleton key={item} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
||||
<Skeleton className="h-[26rem] w-full" />
|
||||
<Skeleton className="h-[26rem] w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="gap-4">
|
||||
<CardHeader className="space-y-3">
|
||||
<Skeleton className="h-5 w-44" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
<Skeleton key={item} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex h-10 items-center border-b border-border px-4">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
</div>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[22%_52%_26%] gap-px bg-border">
|
||||
<div className="space-y-3 bg-background p-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2 bg-background p-3">
|
||||
{Array.from({ length: 12 }).map((_, idx) => (
|
||||
<Skeleton key={idx} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3 bg-background p-4">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { memo, useState, type KeyboardEvent, type MouseEvent } from 'react';
|
||||
import { Copy } from 'lucide-react';
|
||||
import type { LogsEntry } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LogLevelBadge } from './log-level-badge';
|
||||
import { FOCUS_RING, MONO_NUMERIC, ROW_DENSITY, ROW_INTERACTIVE, type RowDensity } from './tokens';
|
||||
import { getDisplayLatency, getDisplayModule, getDisplayRequestId } from './utils';
|
||||
|
||||
export interface LogsRowProps {
|
||||
entry: LogsEntry;
|
||||
isSelected: boolean;
|
||||
density: RowDensity;
|
||||
sourceLabel: string;
|
||||
onSelect: (entryId: string) => void;
|
||||
/** Optional indent (px) when row sits inside an expanded trace. */
|
||||
indent?: number;
|
||||
/** Optional stage chip text (e.g. for child rows of a trace). */
|
||||
stageHint?: string;
|
||||
/** Optional repeat counter — when the row coalesces N identical consecutive entries. */
|
||||
repeatCount?: number;
|
||||
}
|
||||
|
||||
function formatHms(timestamp: string): string {
|
||||
const d = new Date(timestamp);
|
||||
if (Number.isNaN(d.getTime())) return timestamp;
|
||||
return d.toLocaleTimeString(undefined, {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Insecure context or clipboard permission denied. Caller should NOT
|
||||
// claim success in the UI when this returns false.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function LogsRowImpl({
|
||||
entry,
|
||||
isSelected,
|
||||
density,
|
||||
sourceLabel,
|
||||
onSelect,
|
||||
indent = 0,
|
||||
stageHint,
|
||||
repeatCount,
|
||||
}: LogsRowProps) {
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const moduleLabel = getDisplayModule(entry, sourceLabel);
|
||||
const latencyLabel = getDisplayLatency(entry);
|
||||
const shortRequestId = getDisplayRequestId(entry, { short: true });
|
||||
|
||||
const handleSelect = () => onSelect(entry.id);
|
||||
const handleKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
// Ignore keys that bubbled from a nested interactive element (e.g. the
|
||||
// copy-requestId button). Without this guard, pressing Enter on the
|
||||
// copy button would also select the row and shift the detail panel.
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSelect();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyRequestId = async (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!entry.requestId) return;
|
||||
const ok = await copyText(entry.requestId);
|
||||
if (!ok) return;
|
||||
setJustCopied(true);
|
||||
window.setTimeout(() => setJustCopied(false), 1200);
|
||||
};
|
||||
|
||||
// Row is a focusable div (not a button) so it can host a real <button>
|
||||
// for the copy-requestId affordance — nesting interactive elements
|
||||
// inside a <button> is invalid HTML and breaks keyboard focus order.
|
||||
return (
|
||||
<div
|
||||
role="row"
|
||||
tabIndex={0}
|
||||
aria-selected={isSelected}
|
||||
data-selected={isSelected}
|
||||
onClick={handleSelect}
|
||||
onKeyDown={handleKey}
|
||||
style={{ paddingLeft: 12 + indent }}
|
||||
className={cn(
|
||||
'group flex w-full cursor-pointer items-center gap-3 border-b border-border/50 pr-3 text-left',
|
||||
ROW_DENSITY[density],
|
||||
ROW_INTERACTIVE,
|
||||
FOCUS_RING,
|
||||
isSelected && 'bg-muted/60 shadow-[inset_2px_0_0_var(--ring)]'
|
||||
)}
|
||||
>
|
||||
{/* Leading 16px slot mirrors the trace-row chevron column so leaf
|
||||
rows align under the same column edges as trace rows. */}
|
||||
<span className="w-4 shrink-0" aria-hidden="true" />
|
||||
<span
|
||||
role="cell"
|
||||
className={cn('w-[88px] shrink-0 truncate text-[12px] text-muted-foreground', MONO_NUMERIC)}
|
||||
>
|
||||
{formatHms(entry.timestamp)}
|
||||
</span>
|
||||
<span role="cell" className="flex w-[64px] shrink-0 items-center">
|
||||
<LogLevelBadge level={entry.level} />
|
||||
</span>
|
||||
<span
|
||||
role="cell"
|
||||
className="w-[120px] shrink-0 truncate text-[12px] font-medium text-foreground/80"
|
||||
>
|
||||
{moduleLabel}
|
||||
</span>
|
||||
<span
|
||||
role="cell"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 truncate text-[13px] text-foreground/90"
|
||||
>
|
||||
{/* Stage chip renders inline at the start of the message column
|
||||
when a hint is available. Keeps the 7-column grid intact —
|
||||
adding a dedicated stage column squeezed message to 0px at
|
||||
common list-panel widths. */}
|
||||
{stageHint ? (
|
||||
<span className="inline-flex shrink-0 items-center rounded border border-border bg-muted/30 px-1.5 py-0.5 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
{stageHint}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="min-w-0 truncate">{entry.message}</span>
|
||||
{repeatCount && repeatCount > 1 ? (
|
||||
<span
|
||||
className="ml-1 inline-flex shrink-0 items-center rounded bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||
title={`${repeatCount} consecutive identical entries`}
|
||||
>
|
||||
×{repeatCount}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span
|
||||
role="cell"
|
||||
className={cn(
|
||||
'hidden w-[64px] shrink-0 truncate text-right text-[12px] text-muted-foreground sm:inline-block',
|
||||
MONO_NUMERIC
|
||||
)}
|
||||
>
|
||||
{latencyLabel === '—' ? '' : latencyLabel}
|
||||
</span>
|
||||
<span
|
||||
role="cell"
|
||||
className={cn(
|
||||
'hidden w-[100px] shrink-0 items-center justify-end gap-1 text-right text-[12px] text-muted-foreground/80 lg:inline-flex',
|
||||
MONO_NUMERIC
|
||||
)}
|
||||
>
|
||||
{entry.requestId ? (
|
||||
<>
|
||||
<span className="truncate">{shortRequestId}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={justCopied ? 'Copied requestId' : 'Copy requestId'}
|
||||
title={justCopied ? 'Copied' : 'Copy requestId'}
|
||||
onClick={handleCopyRequestId}
|
||||
className={cn(
|
||||
'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground/50 opacity-0 transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100',
|
||||
FOCUS_RING,
|
||||
justCopied && 'text-emerald-600 opacity-100'
|
||||
)}
|
||||
>
|
||||
<Copy className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const LogsRow = memo(LogsRowImpl, (prev, next) => {
|
||||
return (
|
||||
prev.entry.id === next.entry.id &&
|
||||
prev.isSelected === next.isSelected &&
|
||||
prev.density === next.density &&
|
||||
prev.indent === next.indent &&
|
||||
prev.stageHint === next.stageHint &&
|
||||
prev.sourceLabel === next.sourceLabel &&
|
||||
prev.repeatCount === next.repeatCount
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ErrorLogsMonitor } from '@/components/error-logs-monitor';
|
||||
import { LogsConfigCard } from '@/components/logs/logs-config-card';
|
||||
import { LogsDetailPanel } from '@/components/logs/logs-detail-panel';
|
||||
import { LogsEntryList } from '@/components/logs/logs-entry-list';
|
||||
import { LogsEmpty } from '@/components/logs/logs-empty';
|
||||
import { LogsError } from '@/components/logs/logs-error';
|
||||
import { LogsFilters } from '@/components/logs/logs-filters';
|
||||
import { LogsHeader } from '@/components/logs/logs-header';
|
||||
import { LiveTailControls } from '@/components/logs/live-tail-controls';
|
||||
import { LogsShortcutsDialog } from '@/components/logs/logs-shortcuts-dialog';
|
||||
import { useLogsKeyboardNav } from '@/components/logs/use-logs-keyboard-nav';
|
||||
import {
|
||||
getSourceLabelMap,
|
||||
type useLogsWorkspace,
|
||||
type useUpdateLogsConfig,
|
||||
} from '@/hooks/use-logs';
|
||||
|
||||
const LAYOUT_KEY = 'ccs.logs.layout.v1';
|
||||
const DESKTOP_BREAKPOINT = 1200;
|
||||
|
||||
type Workspace = ReturnType<typeof useLogsWorkspace>;
|
||||
type UpdateConfig = ReturnType<typeof useUpdateLogsConfig>;
|
||||
|
||||
interface LayoutSizes {
|
||||
filters: number;
|
||||
list: number;
|
||||
detail: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SIZES: LayoutSizes = { filters: 22, list: 52, detail: 26 };
|
||||
|
||||
function readSavedSizes(): LayoutSizes {
|
||||
if (typeof window === 'undefined') return DEFAULT_SIZES;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(LAYOUT_KEY);
|
||||
if (!raw) return DEFAULT_SIZES;
|
||||
const parsed = JSON.parse(raw) as Partial<LayoutSizes>;
|
||||
if (
|
||||
typeof parsed.filters === 'number' &&
|
||||
typeof parsed.list === 'number' &&
|
||||
typeof parsed.detail === 'number'
|
||||
) {
|
||||
return { filters: parsed.filters, list: parsed.list, detail: parsed.detail };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return DEFAULT_SIZES;
|
||||
}
|
||||
|
||||
function useDesktopLayout(): boolean {
|
||||
const [isDesktop, setIsDesktop] = useState(() =>
|
||||
typeof window === 'undefined' ? true : window.innerWidth >= DESKTOP_BREAKPOINT
|
||||
);
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mq = window.matchMedia(`(min-width: ${DESKTOP_BREAKPOINT}px)`);
|
||||
const sync = () => setIsDesktop(window.innerWidth >= DESKTOP_BREAKPOINT);
|
||||
sync();
|
||||
mq.addEventListener('change', sync);
|
||||
return () => mq.removeEventListener('change', sync);
|
||||
}, []);
|
||||
return isDesktop;
|
||||
}
|
||||
|
||||
type SheetKey = 'settings' | 'detail' | null;
|
||||
|
||||
export interface LogsShellProps {
|
||||
workspace: Workspace;
|
||||
updateConfig: UpdateConfig;
|
||||
}
|
||||
|
||||
export function LogsShell({ workspace, updateConfig }: LogsShellProps) {
|
||||
const isDesktop = useDesktopLayout();
|
||||
const sourceLabels = useMemo(
|
||||
() => getSourceLabelMap(workspace.sourcesQuery.data ?? []),
|
||||
[workspace.sourcesQuery.data]
|
||||
);
|
||||
const [activeSheet, setActiveSheet] = useState<SheetKey>(null);
|
||||
const [sizes, setSizes] = useState<LayoutSizes>(readSavedSizes);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const config = workspace.configQuery.data;
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void Promise.all([workspace.sourcesQuery.refetch(), workspace.entriesQuery.refetch()]);
|
||||
}, [workspace.entriesQuery, workspace.sourcesQuery]);
|
||||
|
||||
const onLayout = useCallback((next: number[]) => {
|
||||
if (next.length !== 3) return;
|
||||
const [filters, list, detail] = next as [number, number, number];
|
||||
const updated = { filters, list, detail };
|
||||
setSizes(updated);
|
||||
try {
|
||||
window.localStorage.setItem(LAYOUT_KEY, JSON.stringify(updated));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openSettings = useCallback(() => setActiveSheet('settings'), []);
|
||||
const openDetailSheet = useCallback(() => setActiveSheet('detail'), []);
|
||||
const closeSheet = useCallback(() => setActiveSheet(null), []);
|
||||
|
||||
// On mobile, selecting an entry opens the detail bottom-sheet.
|
||||
// We wrap `setSelectedEntryId` so it stays an event-driven side effect.
|
||||
const baseSetSelectedEntryId = workspace.setSelectedEntryId;
|
||||
const handleSelectEntry = useCallback(
|
||||
(id: string | null) => {
|
||||
baseSetSelectedEntryId(id);
|
||||
if (!isDesktop && id) setActiveSheet('detail');
|
||||
},
|
||||
[baseSetSelectedEntryId, isDesktop]
|
||||
);
|
||||
|
||||
const handleShowTrace = useCallback(
|
||||
(requestId: string) => {
|
||||
workspace.setRequestIdFilter(requestId);
|
||||
workspace.setSearch('');
|
||||
},
|
||||
[workspace]
|
||||
);
|
||||
|
||||
const entryIds = useMemo(
|
||||
() => (workspace.entriesQuery.data ?? []).map((e) => e.id),
|
||||
[workspace.entriesQuery.data]
|
||||
);
|
||||
|
||||
// Derive header stat strip values once per data refetch.
|
||||
const headerStats = useMemo(() => {
|
||||
const data = workspace.entriesQuery.data ?? [];
|
||||
const requestIds = new Set<string>();
|
||||
let errors = 0;
|
||||
for (const entry of data) {
|
||||
if (entry.requestId) requestIds.add(entry.requestId);
|
||||
if (entry.level === 'error') errors += 1;
|
||||
}
|
||||
return { entries: data.length, traces: requestIds.size, errors };
|
||||
}, [workspace.entriesQuery.data]);
|
||||
|
||||
const focusSearch = useCallback(() => {
|
||||
const input = document.getElementById('logs-search') as HTMLInputElement | null;
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
} else if (searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useLogsKeyboardNav({
|
||||
entryIds,
|
||||
selectedId: workspace.selectedEntryId,
|
||||
onSelect: workspace.setSelectedEntryId,
|
||||
onTogglePause: workspace.liveTail.togglePause,
|
||||
onFocusSearch: focusSearch,
|
||||
onOpenShortcuts: () => setShortcutsOpen(true),
|
||||
onCloseDetail: !isDesktop && activeSheet === 'detail' ? closeSheet : undefined,
|
||||
});
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const filtersNode = (
|
||||
<LogsFilters
|
||||
sources={workspace.sourcesQuery.data ?? []}
|
||||
selectedSource={workspace.selectedSource}
|
||||
onSourceChange={workspace.setSelectedSource}
|
||||
selectedLevel={workspace.selectedLevel}
|
||||
onLevelChange={workspace.setSelectedLevel}
|
||||
search={workspace.search}
|
||||
onSearchChange={workspace.setSearch}
|
||||
limit={workspace.limit}
|
||||
onLimitChange={workspace.setLimit}
|
||||
onRefresh={handleRefresh}
|
||||
isRefreshing={workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching}
|
||||
moduleFilter={workspace.moduleFilter}
|
||||
onModuleChange={workspace.setModuleFilter}
|
||||
stageFilter={workspace.stageFilter}
|
||||
onStageChange={workspace.setStageFilter}
|
||||
requestIdFilter={workspace.requestIdFilter}
|
||||
onRequestIdChange={workspace.setRequestIdFilter}
|
||||
timeWindow={workspace.timeWindow}
|
||||
onTimeWindowChange={workspace.setTimeWindow}
|
||||
hideDashboardInternals={workspace.hideDashboardInternals}
|
||||
onHideDashboardInternalsChange={workspace.setHideDashboardInternals}
|
||||
onClearAll={workspace.clearAdvancedFilters}
|
||||
/>
|
||||
);
|
||||
|
||||
const liveTailSlot = (
|
||||
<LiveTailControls
|
||||
isPaused={workspace.liveTail.isPaused}
|
||||
pendingCount={workspace.liveTail.pendingCount}
|
||||
onTogglePause={workspace.liveTail.togglePause}
|
||||
/>
|
||||
);
|
||||
|
||||
const listNode = workspace.entriesQuery.error ? (
|
||||
<LogsError
|
||||
error={workspace.entriesQuery.error as Error}
|
||||
onRetry={() => void workspace.entriesQuery.refetch()}
|
||||
/>
|
||||
) : (
|
||||
<LogsEntryList
|
||||
entries={workspace.entriesQuery.data ?? []}
|
||||
selectedEntryId={workspace.selectedEntryId}
|
||||
onSelect={handleSelectEntry}
|
||||
sourceLabels={sourceLabels}
|
||||
isLoading={workspace.entriesQuery.isLoading}
|
||||
isFetching={workspace.entriesQuery.isFetching}
|
||||
liveTailSlot={liveTailSlot}
|
||||
/>
|
||||
);
|
||||
|
||||
const detailNode = workspace.isSelectionOutOfScope ? (
|
||||
<LogsEmpty variant="selectionOutOfScope" onClearFilters={workspace.clearAdvancedFilters} />
|
||||
) : (
|
||||
<LogsDetailPanel
|
||||
entry={workspace.selectedEntry}
|
||||
sourceLabel={
|
||||
workspace.selectedEntry ? sourceLabels[workspace.selectedEntry.source] : undefined
|
||||
}
|
||||
onShowTrace={handleShowTrace}
|
||||
redact={Boolean(config.redact)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
|
||||
<LogsHeader
|
||||
isFetching={workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching}
|
||||
hasError={Boolean(workspace.entriesQuery.error)}
|
||||
capturedCount={workspace.entriesQuery.data?.length ?? 0}
|
||||
stats={headerStats}
|
||||
onRefresh={handleRefresh}
|
||||
onOpenSettings={openSettings}
|
||||
onOpenShortcuts={() => setShortcutsOpen(true)}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="stream" className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="flex h-10 shrink-0 items-center border-b border-border bg-background px-4">
|
||||
<TabsList className="h-8 bg-muted/40">
|
||||
<TabsTrigger value="stream" className="text-xs">
|
||||
Stream
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="errors" className="text-xs">
|
||||
Upstream errors
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent
|
||||
value="stream"
|
||||
className="m-0 flex min-h-0 flex-1 overflow-hidden focus-visible:outline-none"
|
||||
>
|
||||
{isDesktop ? (
|
||||
<PanelGroup
|
||||
direction="horizontal"
|
||||
onLayout={onLayout}
|
||||
className="flex min-h-0 flex-1"
|
||||
autoSaveId={LAYOUT_KEY}
|
||||
>
|
||||
<Panel defaultSize={sizes.filters} minSize={16} maxSize={36} order={1}>
|
||||
<div className="flex h-full min-h-0 flex-col overflow-y-auto border-r border-border bg-background p-4">
|
||||
{filtersNode}
|
||||
</div>
|
||||
</Panel>
|
||||
<PanelResizeHandle className="w-px bg-border transition-colors hover:bg-foreground/20 data-[resize-handle-active]:bg-foreground/30" />
|
||||
<Panel defaultSize={sizes.list} minSize={32} order={2}>
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
|
||||
{listNode}
|
||||
</div>
|
||||
</Panel>
|
||||
<PanelResizeHandle className="w-px bg-border transition-colors hover:bg-foreground/20 data-[resize-handle-active]:bg-foreground/30" />
|
||||
<Panel defaultSize={sizes.detail} minSize={20} maxSize={42} order={3}>
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden border-l border-border bg-background">
|
||||
{detailNode}
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="border-b border-border p-3">{filtersNode}</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">{listNode}</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="errors"
|
||||
className="m-0 flex-1 overflow-y-auto p-4 focus-visible:outline-none"
|
||||
>
|
||||
<div className="mx-auto max-w-5xl space-y-4">
|
||||
<header className="space-y-1">
|
||||
<h2 className="text-base font-semibold text-foreground">CLIProxy upstream errors</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Historical failure stream from the upstream proxy. Distinct from the structured
|
||||
stream.
|
||||
</p>
|
||||
</header>
|
||||
<ErrorLogsMonitor />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Logging settings sheet (right). Phase-05 may replace body with extracted form. */}
|
||||
<Sheet
|
||||
open={activeSheet === 'settings'}
|
||||
onOpenChange={(open) => (open ? openSettings() : closeSheet())}
|
||||
>
|
||||
<SheetContent side="right" className="w-full max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Logging settings</SheetTitle>
|
||||
<SheetDescription>
|
||||
Configure retention, redaction, and sampling for the logs surface.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="mt-4 px-4 pb-6">
|
||||
<LogsConfigCard
|
||||
config={config}
|
||||
onSave={(payload) => updateConfig.mutate(payload)}
|
||||
isPending={updateConfig.isPending}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<LogsShortcutsDialog open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
|
||||
|
||||
{/* Mobile detail sheet (bottom). Desktop renders detail in panel directly. */}
|
||||
{!isDesktop ? (
|
||||
<Sheet
|
||||
open={activeSheet === 'detail'}
|
||||
onOpenChange={(open) => (open ? openDetailSheet() : closeSheet())}
|
||||
>
|
||||
<SheetContent side="bottom" className="h-[85vh]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Entry detail</SheetTitle>
|
||||
<SheetDescription>Selected log entry context and raw payload.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="mt-2 flex min-h-0 flex-1 overflow-hidden">{detailNode}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export interface LogsShortcutsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const SHORTCUTS: Array<{ keys: string[]; label: string }> = [
|
||||
{ keys: ['j', '↓'], label: 'Next entry' },
|
||||
{ keys: ['k', '↑'], label: 'Previous entry' },
|
||||
{ keys: ['Enter'], label: 'Focus detail' },
|
||||
{ keys: ['Esc'], label: 'Close detail (mobile)' },
|
||||
{ keys: ['/'], label: 'Focus search' },
|
||||
{ keys: ['Space'], label: 'Pause / resume tail' },
|
||||
{ keys: ['?'], label: 'Show this help' },
|
||||
];
|
||||
|
||||
function Kbd({ children }: { children: string }) {
|
||||
return (
|
||||
<kbd className="inline-flex h-5 min-w-[20px] items-center justify-center rounded border border-border bg-muted px-1.5 font-mono text-[11px] font-medium text-foreground">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogsShortcutsDialog({ open, onOpenChange }: LogsShortcutsDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Keyboard shortcuts</DialogTitle>
|
||||
<DialogDescription>Logs surface keybindings.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{SHORTCUTS.map((s) => (
|
||||
<li key={s.label} className="flex items-center justify-between text-sm">
|
||||
<span className="text-foreground/90">{s.label}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{s.keys.map((k) => (
|
||||
<Kbd key={k}>{k}</Kbd>
|
||||
))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { memo } from 'react';
|
||||
import { ChevronDown, ChevronRight, GitBranch } from 'lucide-react';
|
||||
import type { LogsEntry, LogsLevel } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { deriveStageHint } from './derive-trace-groups';
|
||||
import { LogLevelBadge } from './log-level-badge';
|
||||
import { LogsRow } from './logs-row';
|
||||
import { FOCUS_RING, MONO_NUMERIC, ROW_DENSITY, ROW_INTERACTIVE, type RowDensity } from './tokens';
|
||||
|
||||
export interface TraceGroup {
|
||||
kind: 'trace';
|
||||
requestId: string;
|
||||
module: string;
|
||||
source: string;
|
||||
ts: string; // min child ts
|
||||
maxLevel: LogsLevel;
|
||||
totalLatencyMs: number;
|
||||
children: LogsEntry[];
|
||||
}
|
||||
|
||||
export interface LogsTraceRowProps {
|
||||
group: TraceGroup;
|
||||
isExpanded: boolean;
|
||||
selectedEntryId: string | null;
|
||||
density: RowDensity;
|
||||
sourceLabel: string;
|
||||
onToggle: (requestId: string) => void;
|
||||
onSelect: (entryId: string) => void;
|
||||
}
|
||||
|
||||
function formatHms(timestamp: string): string {
|
||||
const d = new Date(timestamp);
|
||||
if (Number.isNaN(d.getTime())) return timestamp;
|
||||
return d.toLocaleTimeString(undefined, {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function LogsTraceRowImpl({
|
||||
group,
|
||||
isExpanded,
|
||||
selectedEntryId,
|
||||
density,
|
||||
sourceLabel,
|
||||
onToggle,
|
||||
onSelect,
|
||||
}: LogsTraceRowProps) {
|
||||
const Chevron = isExpanded ? ChevronDown : ChevronRight;
|
||||
return (
|
||||
<div role="rowgroup" className="border-b border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
role="row"
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() => onToggle(group.requestId)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 pl-3 pr-3 text-left',
|
||||
ROW_DENSITY[density],
|
||||
ROW_INTERACTIVE,
|
||||
FOCUS_RING
|
||||
)}
|
||||
>
|
||||
{/* Reserve the same 16px slot as leaf rows so all columns align. */}
|
||||
<span className="flex w-4 shrink-0 items-center justify-center" aria-hidden="true">
|
||||
<Chevron className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
<span
|
||||
role="cell"
|
||||
className={cn(
|
||||
'w-[88px] shrink-0 truncate text-[12px] text-muted-foreground',
|
||||
MONO_NUMERIC
|
||||
)}
|
||||
>
|
||||
{formatHms(group.ts)}
|
||||
</span>
|
||||
<span role="cell" className="flex w-[64px] shrink-0 items-center">
|
||||
<LogLevelBadge level={group.maxLevel} />
|
||||
</span>
|
||||
<span
|
||||
role="cell"
|
||||
className="flex w-[120px] shrink-0 items-center gap-1.5 truncate text-[12px] font-medium text-foreground/80"
|
||||
>
|
||||
<GitBranch className="h-3 w-3 text-muted-foreground" aria-hidden="true" />
|
||||
{group.module}
|
||||
</span>
|
||||
<span role="cell" className="min-w-0 flex-1 truncate text-[13px] text-foreground/90">
|
||||
<span className="text-muted-foreground">trace · {group.children.length} stages</span>
|
||||
</span>
|
||||
<span
|
||||
role="cell"
|
||||
className={cn(
|
||||
'hidden w-[64px] shrink-0 truncate text-right text-[12px] text-muted-foreground sm:inline-block',
|
||||
MONO_NUMERIC
|
||||
)}
|
||||
>
|
||||
{group.totalLatencyMs > 0 ? `${group.totalLatencyMs}ms` : ''}
|
||||
</span>
|
||||
<span
|
||||
role="cell"
|
||||
className={cn(
|
||||
'hidden w-[100px] shrink-0 truncate text-right text-[12px] text-muted-foreground lg:inline-block',
|
||||
MONO_NUMERIC
|
||||
)}
|
||||
>
|
||||
{group.requestId.slice(-8)}
|
||||
</span>
|
||||
</button>
|
||||
{/* Children render unconditionally — every stage is preserved so
|
||||
retries, duplicated stage logs, and multi-attempt traces stay
|
||||
individually inspectable. The user-facing noise problem this
|
||||
PR addresses lives at the standalone-leaf level (dashboard
|
||||
self-polling spam) where leaf-coalesce in deriveTraceGroups
|
||||
handles it; opting in to see internals is a deliberate debug
|
||||
choice, so collapsing trace stages there would hide intent. */}
|
||||
{isExpanded
|
||||
? group.children.map((child) => (
|
||||
<LogsRow
|
||||
key={child.id}
|
||||
entry={child}
|
||||
isSelected={child.id === selectedEntryId}
|
||||
density={density}
|
||||
sourceLabel={sourceLabel}
|
||||
onSelect={onSelect}
|
||||
indent={20}
|
||||
stageHint={deriveStageHint(child)}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const LogsTraceRow = memo(LogsTraceRowImpl);
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { LogsLevel } from '@/lib/api-client';
|
||||
|
||||
/**
|
||||
* Calm-density design tokens for the logs surface.
|
||||
*
|
||||
* Strings only -- no runtime cost. Components import these instead of
|
||||
* duplicating Tailwind utilities. Color values use neutral red/amber/blue/zinc
|
||||
* scales tuned for AA contrast on `bg-background` in both light and dark
|
||||
* themes.
|
||||
*/
|
||||
|
||||
export interface LevelToken {
|
||||
bg: string;
|
||||
fg: string;
|
||||
border: string;
|
||||
dot: string;
|
||||
}
|
||||
|
||||
export const LEVEL_TOKENS: Record<LogsLevel, LevelToken> = {
|
||||
error: {
|
||||
bg: 'bg-red-500/10',
|
||||
fg: 'text-red-700 dark:text-red-300',
|
||||
border: 'border-red-500/30',
|
||||
dot: 'bg-red-500',
|
||||
},
|
||||
warn: {
|
||||
bg: 'bg-amber-500/10',
|
||||
fg: 'text-amber-800 dark:text-amber-200',
|
||||
border: 'border-amber-500/30',
|
||||
dot: 'bg-amber-500',
|
||||
},
|
||||
info: {
|
||||
bg: 'bg-sky-500/10',
|
||||
fg: 'text-sky-800 dark:text-sky-200',
|
||||
border: 'border-sky-500/30',
|
||||
dot: 'bg-sky-500',
|
||||
},
|
||||
debug: {
|
||||
bg: 'bg-zinc-500/10',
|
||||
fg: 'text-zinc-700 dark:text-zinc-300',
|
||||
border: 'border-zinc-500/30',
|
||||
dot: 'bg-zinc-500',
|
||||
},
|
||||
};
|
||||
|
||||
export const ROW_DENSITY = {
|
||||
compact: 'h-8 text-[12px]',
|
||||
cozy: 'h-10 text-[13px]',
|
||||
} as const;
|
||||
|
||||
export type RowDensity = keyof typeof ROW_DENSITY;
|
||||
|
||||
export const PANEL_CHROME = 'border-border bg-background';
|
||||
|
||||
export const PANEL_CHROME_RIGHT = 'border-r border-border bg-background';
|
||||
export const PANEL_CHROME_LEFT = 'border-l border-border bg-background';
|
||||
|
||||
/** Mono numerics for timestamp / latency columns. */
|
||||
export const MONO_NUMERIC = 'font-mono tabular-nums';
|
||||
|
||||
/** Subtle row hover/selected feedback. */
|
||||
export const ROW_INTERACTIVE =
|
||||
'transition-colors hover:bg-muted/40 data-[selected=true]:bg-muted/60';
|
||||
|
||||
/** Calm focus ring used across logs interactive elements. */
|
||||
export const FOCUS_RING =
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background';
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface UseLogsKeyboardNavOptions {
|
||||
/** Currently visible entry ids in display order (post-filter, post-grouping flatten). */
|
||||
entryIds: string[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onTogglePause: () => void;
|
||||
onFocusSearch: () => void;
|
||||
onOpenShortcuts: () => void;
|
||||
/** Detail-sheet close (mobile only). Optional. */
|
||||
onCloseDetail?: () => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
return target.isContentEditable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralised keyboard navigation for the logs surface.
|
||||
* Bound on `window`. Bail out when typing in form fields or the user has
|
||||
* any modifier (cmd/ctrl/alt) other than shift on `?`.
|
||||
*/
|
||||
export function useLogsKeyboardNav({
|
||||
entryIds,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onTogglePause,
|
||||
onFocusSearch,
|
||||
onOpenShortcuts,
|
||||
onCloseDetail,
|
||||
enabled = true,
|
||||
}: UseLogsKeyboardNavOptions): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
|
||||
// `/` always focuses search, even from inputs (consistent with most apps)
|
||||
if (event.key === '/') {
|
||||
event.preventDefault();
|
||||
onFocusSearch();
|
||||
return;
|
||||
}
|
||||
if (event.key === '?') {
|
||||
event.preventDefault();
|
||||
onOpenShortcuts();
|
||||
return;
|
||||
}
|
||||
if (isTypingTarget(event.target)) return;
|
||||
|
||||
if (event.key === 'Escape' && onCloseDetail) {
|
||||
onCloseDetail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onTogglePause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'j' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
if (entryIds.length === 0) return;
|
||||
const idx = selectedId ? entryIds.indexOf(selectedId) : -1;
|
||||
const next = entryIds[Math.min(idx + 1, entryIds.length - 1)] ?? entryIds[0];
|
||||
if (next) onSelect(next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'k' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
if (entryIds.length === 0) return;
|
||||
const idx = selectedId ? entryIds.indexOf(selectedId) : 0;
|
||||
const next = entryIds[Math.max(idx - 1, 0)] ?? entryIds[0];
|
||||
if (next) onSelect(next);
|
||||
return;
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [
|
||||
enabled,
|
||||
entryIds,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onTogglePause,
|
||||
onFocusSearch,
|
||||
onOpenShortcuts,
|
||||
onCloseDetail,
|
||||
]);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LogsLevel } from '@/lib/api-client';
|
||||
import type { LogsEntry, LogsLevel } from '@/lib/api-client';
|
||||
// NOTE: This module contains utility functions that are not directly i18n-aware.
|
||||
// String literals here ("No activity yet", "Error", etc.) are used as fallbacks
|
||||
// and defaults in non-component contexts. Components consuming these values
|
||||
@@ -80,3 +80,37 @@ export function getLevelLabel(level: LogsLevel) {
|
||||
return 'Debug';
|
||||
}
|
||||
}
|
||||
|
||||
// Field accessors shared by list row + detail panel.
|
||||
// Without these, list and detail diverged — list fell back to source/sourceLabel
|
||||
// while detail showed `—` for the same entry. Single source of truth.
|
||||
|
||||
export function getDisplayModule(entry: LogsEntry, sourceLabel?: string): string {
|
||||
return entry.module ?? sourceLabel ?? entry.source ?? '—';
|
||||
}
|
||||
|
||||
export function getDisplayStage(entry: LogsEntry): string {
|
||||
return entry.stage ?? '—';
|
||||
}
|
||||
|
||||
export function getDisplayRequestId(entry: LogsEntry, options: { short?: boolean } = {}): string {
|
||||
if (!entry.requestId) return '—';
|
||||
return options.short ? entry.requestId.slice(-8) : entry.requestId;
|
||||
}
|
||||
|
||||
export function getDisplayLatency(entry: LogsEntry): string {
|
||||
if (entry.latencyMs === undefined || entry.latencyMs === null) return '—';
|
||||
return `${entry.latencyMs}ms`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default filter pattern: dashboard self-polling sources start with `web-server:`.
|
||||
* UI applies as a default exclusion to keep the logs view focused on signal,
|
||||
* not the dashboard observing itself.
|
||||
*/
|
||||
export const DASHBOARD_INTERNALS_PATTERN = /^web-server:/i;
|
||||
|
||||
export function isDashboardInternal(entry: LogsEntry): boolean {
|
||||
if (!entry.source) return false;
|
||||
return DASHBOARD_INTERNALS_PATTERN.test(entry.source);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useDeferredValue, useMemo, useState } from 'react';
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -9,20 +9,124 @@ import {
|
||||
type UpdateLogsConfigPayload,
|
||||
} from '@/lib/api-client';
|
||||
|
||||
// ---- dev-only fixture injection (`?mock=logs`) -----------------------------
|
||||
// Tree-shaken from production: the fixture import lives behind
|
||||
// `import.meta.env.DEV` and the URL flag check.
|
||||
const KNOWN_LOGS_ENTRY_KEYS = new Set([
|
||||
'id',
|
||||
'timestamp',
|
||||
'level',
|
||||
'source',
|
||||
'event',
|
||||
'message',
|
||||
'processId',
|
||||
'runId',
|
||||
'context',
|
||||
'requestId',
|
||||
'module',
|
||||
'stage',
|
||||
'latencyMs',
|
||||
'metadata',
|
||||
'error',
|
||||
]);
|
||||
|
||||
let _shapeWarned = false;
|
||||
function assertLogsEntryShape(entry: LogsEntry): void {
|
||||
if (!import.meta.env.DEV || _shapeWarned) return;
|
||||
const missing: string[] = [];
|
||||
if (!entry.id) missing.push('id');
|
||||
if (!entry.timestamp) missing.push('timestamp');
|
||||
if (!entry.level) missing.push('level');
|
||||
const unknown = Object.keys(entry).filter((k) => !KNOWN_LOGS_ENTRY_KEYS.has(k));
|
||||
if (missing.length || unknown.length) {
|
||||
_shapeWarned = true;
|
||||
|
||||
console.warn('[logs] LogsEntry shape drift detected -- backend contract changed?', {
|
||||
missing,
|
||||
unknown,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isMockLogsEnabled(): boolean {
|
||||
if (!import.meta.env.DEV) return false;
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).get('mock') === 'logs';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type LogsLevelFilter = 'all' | LogsLevel;
|
||||
export type LogsSourceFilter = 'all' | string;
|
||||
export type LogsTimeWindow = 'all' | '5m' | '15m' | '1h' | '24h';
|
||||
|
||||
const CONFIG_QUERY_KEY = ['logs', 'config'] as const;
|
||||
const SOURCES_QUERY_KEY = ['logs', 'sources'] as const;
|
||||
const DEFAULT_LIMIT = 150;
|
||||
const POLL_INTERVAL_MS = 10_000;
|
||||
const TEXT_DEBOUNCE_MS = 250;
|
||||
|
||||
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [value, delayMs]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
function useDocumentVisible(): boolean {
|
||||
const [visible, setVisible] = useState(() =>
|
||||
typeof document === 'undefined' ? true : document.visibilityState !== 'hidden'
|
||||
);
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const sync = () => setVisible(document.visibilityState !== 'hidden');
|
||||
document.addEventListener('visibilitychange', sync);
|
||||
return () => document.removeEventListener('visibilitychange', sync);
|
||||
}, []);
|
||||
return visible;
|
||||
}
|
||||
|
||||
function windowToCutoffMs(window: LogsTimeWindow): number | null {
|
||||
switch (window) {
|
||||
case '5m':
|
||||
return 5 * 60_000;
|
||||
case '15m':
|
||||
return 15 * 60_000;
|
||||
case '1h':
|
||||
return 60 * 60_000;
|
||||
case '24h':
|
||||
return 24 * 60 * 60_000;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useLogsWorkspace() {
|
||||
const [selectedSource, setSelectedSource] = useState<LogsSourceFilter>('all');
|
||||
const [selectedLevel, setSelectedLevel] = useState<LogsLevelFilter>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [moduleFilter, setModuleFilter] = useState('');
|
||||
const [stageFilter, setStageFilter] = useState('');
|
||||
const [requestIdFilter, setRequestIdFilter] = useState('');
|
||||
const [timeWindow, setTimeWindow] = useState<LogsTimeWindow>('all');
|
||||
const [limit, setLimit] = useState(DEFAULT_LIMIT);
|
||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
// Default ON: dashboard self-polling generates 100s of identical entries
|
||||
// per refresh which drown out real provider activity. Users can opt in to
|
||||
// see internals via the advanced filter toggle.
|
||||
const [hideDashboardInternals, setHideDashboardInternals] = useState(true);
|
||||
const frozenIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const deferredSearch = useDeferredValue(search.trim());
|
||||
const debouncedModule = useDebouncedValue(moduleFilter.trim(), TEXT_DEBOUNCE_MS);
|
||||
const debouncedStage = useDebouncedValue(stageFilter.trim(), TEXT_DEBOUNCE_MS);
|
||||
const debouncedRequestId = useDebouncedValue(requestIdFilter.trim(), TEXT_DEBOUNCE_MS);
|
||||
const documentVisible = useDocumentVisible();
|
||||
|
||||
const configQuery = useQuery({
|
||||
queryKey: CONFIG_QUERY_KEY,
|
||||
@@ -36,37 +140,122 @@ export function useLogsWorkspace() {
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const mockEnabled = isMockLogsEnabled();
|
||||
const refetchInterval = isPaused || !documentVisible ? false : POLL_INTERVAL_MS;
|
||||
|
||||
const entriesQuery = useQuery({
|
||||
queryKey: ['logs', 'entries', selectedSource, selectedLevel, deferredSearch, limit],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await api.logs.getEntries({
|
||||
queryKey: [
|
||||
'logs',
|
||||
'entries',
|
||||
selectedSource,
|
||||
selectedLevel,
|
||||
deferredSearch,
|
||||
debouncedModule,
|
||||
debouncedStage,
|
||||
debouncedRequestId,
|
||||
timeWindow,
|
||||
limit,
|
||||
hideDashboardInternals ? 'hide-internals' : 'show-internals',
|
||||
mockEnabled ? 'mock' : 'live',
|
||||
],
|
||||
queryFn: async () => {
|
||||
let entries: LogsEntry[];
|
||||
if (mockEnabled && import.meta.env.DEV) {
|
||||
const { STRUCTURED_LOG_ENTRIES } =
|
||||
await import('@/components/logs/__fixtures__/structured-log-entries');
|
||||
entries = STRUCTURED_LOG_ENTRIES.filter((entry) => {
|
||||
if (selectedSource !== 'all' && entry.source !== selectedSource) return false;
|
||||
if (selectedLevel !== 'all' && entry.level !== selectedLevel) return false;
|
||||
if (deferredSearch) {
|
||||
const needle = deferredSearch.toLowerCase();
|
||||
const hay =
|
||||
`${entry.message} ${entry.event} ${entry.module ?? ''} ${entry.requestId ?? ''}`.toLowerCase();
|
||||
if (!hay.includes(needle)) return false;
|
||||
}
|
||||
return true;
|
||||
}).slice(0, limit);
|
||||
} else {
|
||||
const result = await api.logs.getEntries({
|
||||
source: selectedSource === 'all' ? undefined : selectedSource,
|
||||
level: selectedLevel === 'all' ? undefined : selectedLevel,
|
||||
search: deferredSearch || undefined,
|
||||
limit,
|
||||
})
|
||||
).entries,
|
||||
});
|
||||
entries = result.entries;
|
||||
}
|
||||
|
||||
// Client-side fallback for advanced filters (backend may not yet implement).
|
||||
const cutoffMs = windowToCutoffMs(timeWindow);
|
||||
const now = Date.now();
|
||||
const filtered = entries.filter((entry) => {
|
||||
if (debouncedModule && !(entry.module ?? '').includes(debouncedModule)) return false;
|
||||
if (debouncedStage && (entry.stage ?? '') !== debouncedStage) return false;
|
||||
if (debouncedRequestId && !(entry.requestId ?? '').includes(debouncedRequestId))
|
||||
return false;
|
||||
if (cutoffMs !== null) {
|
||||
const ts = Date.parse(entry.timestamp);
|
||||
if (Number.isFinite(ts) && now - ts > cutoffMs) return false;
|
||||
}
|
||||
// Hide dashboard self-polling unless user opted in. Preserves the
|
||||
// signal-to-noise ratio for fresh-load investigations.
|
||||
if (hideDashboardInternals && /^web-server:/i.test(entry.source)) return false;
|
||||
return true;
|
||||
});
|
||||
const head = filtered[0];
|
||||
if (head) assertLogsEntryShape(head);
|
||||
return filtered;
|
||||
},
|
||||
placeholderData: keepPreviousData,
|
||||
refetchInterval: 10_000,
|
||||
refetchInterval,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const activeSelectedEntryId = useMemo(() => {
|
||||
const nextEntries = entriesQuery.data ?? [];
|
||||
if (nextEntries.length === 0) {
|
||||
return null;
|
||||
// Live-tail pending count: rotation-safe id-set diff.
|
||||
const entriesData = entriesQuery.data;
|
||||
const backendEntries = useMemo<LogsEntry[]>(() => entriesData ?? [], [entriesData]);
|
||||
const pendingCount = useMemo(() => {
|
||||
if (!isPaused) return 0;
|
||||
const frozen = frozenIdsRef.current;
|
||||
let count = 0;
|
||||
for (const e of backendEntries) {
|
||||
if (!frozen.has(e.id)) count += 1;
|
||||
}
|
||||
return count;
|
||||
}, [backendEntries, isPaused]);
|
||||
|
||||
if (selectedEntryId && nextEntries.some((entry) => entry.id === selectedEntryId)) {
|
||||
const pause = useCallback(() => {
|
||||
frozenIdsRef.current = new Set(backendEntries.map((e) => e.id));
|
||||
setIsPaused(true);
|
||||
}, [backendEntries]);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
frozenIdsRef.current = new Set();
|
||||
setIsPaused(false);
|
||||
void entriesQuery.refetch();
|
||||
}, [entriesQuery]);
|
||||
|
||||
const togglePause = useCallback(() => {
|
||||
if (isPaused) resume();
|
||||
else pause();
|
||||
}, [isPaused, pause, resume]);
|
||||
|
||||
// Selection: keep selectedId across refetch, fall back to first entry.
|
||||
const activeSelectedEntryId = useMemo(() => {
|
||||
if (backendEntries.length === 0) return null;
|
||||
if (selectedEntryId && backendEntries.some((entry) => entry.id === selectedEntryId)) {
|
||||
return selectedEntryId;
|
||||
}
|
||||
|
||||
return nextEntries[0]?.id ?? null;
|
||||
}, [entriesQuery.data, selectedEntryId]);
|
||||
return backendEntries[0]?.id ?? null;
|
||||
}, [backendEntries, selectedEntryId]);
|
||||
|
||||
const selectedEntry = useMemo(
|
||||
() => (entriesQuery.data ?? []).find((entry) => entry.id === activeSelectedEntryId) ?? null,
|
||||
[activeSelectedEntryId, entriesQuery.data]
|
||||
() => backendEntries.find((entry) => entry.id === activeSelectedEntryId) ?? null,
|
||||
[activeSelectedEntryId, backendEntries]
|
||||
);
|
||||
|
||||
// selectionOutOfScope: a selection survived but is no longer in filtered entries.
|
||||
const isSelectionOutOfScope = Boolean(
|
||||
selectedEntryId && !backendEntries.some((entry) => entry.id === selectedEntryId)
|
||||
);
|
||||
|
||||
const latestTimestamp = useMemo(() => {
|
||||
@@ -76,6 +265,17 @@ export function useLogsWorkspace() {
|
||||
return timestamps.sort((left, right) => right.localeCompare(left))[0] ?? null;
|
||||
}, [sourcesQuery.data]);
|
||||
|
||||
const clearAdvancedFilters = useCallback(() => {
|
||||
setSearch('');
|
||||
setSelectedLevel('all');
|
||||
setSelectedSource('all');
|
||||
setModuleFilter('');
|
||||
setStageFilter('');
|
||||
setRequestIdFilter('');
|
||||
setTimeWindow('all');
|
||||
setHideDashboardInternals(true);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
configQuery,
|
||||
sourcesQuery,
|
||||
@@ -86,16 +286,35 @@ export function useLogsWorkspace() {
|
||||
setSelectedLevel,
|
||||
search,
|
||||
setSearch,
|
||||
moduleFilter,
|
||||
setModuleFilter,
|
||||
stageFilter,
|
||||
setStageFilter,
|
||||
requestIdFilter,
|
||||
setRequestIdFilter,
|
||||
timeWindow,
|
||||
setTimeWindow,
|
||||
hideDashboardInternals,
|
||||
setHideDashboardInternals,
|
||||
limit,
|
||||
setLimit,
|
||||
selectedEntryId: activeSelectedEntryId,
|
||||
setSelectedEntryId,
|
||||
selectedEntry,
|
||||
isSelectionOutOfScope,
|
||||
latestTimestamp,
|
||||
isInitialLoading:
|
||||
(!configQuery.data && configQuery.isLoading) ||
|
||||
(!sourcesQuery.data && sourcesQuery.isLoading) ||
|
||||
(!entriesQuery.data && entriesQuery.isLoading),
|
||||
liveTail: {
|
||||
isPaused,
|
||||
pendingCount,
|
||||
pause,
|
||||
resume,
|
||||
togglePause,
|
||||
},
|
||||
clearAdvancedFilters,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,6 +348,16 @@ export function getLogLevelOptions(): Array<{ value: LogsLevelFilter; label: str
|
||||
];
|
||||
}
|
||||
|
||||
export function getLogsTimeWindowOptions(): Array<{ value: LogsTimeWindow; label: string }> {
|
||||
return [
|
||||
{ value: 'all', label: 'All time' },
|
||||
{ value: '5m', label: 'Last 5m' },
|
||||
{ value: '15m', label: 'Last 15m' },
|
||||
{ value: '1h', label: 'Last 1h' },
|
||||
{ value: '24h', label: 'Last 24h' },
|
||||
];
|
||||
}
|
||||
|
||||
export function getSelectedSourceLabel(
|
||||
source: LogsSourceFilter,
|
||||
sources: Array<{ source: string; label: string }>
|
||||
|
||||
@@ -981,6 +981,14 @@ export interface LogsEntry {
|
||||
processId: number | null;
|
||||
runId: string | null;
|
||||
context?: unknown;
|
||||
// Structured-log fields from #1141 contract. All optional; UI ships first,
|
||||
// backend fills these incrementally. Order is stable and additive.
|
||||
requestId?: string;
|
||||
module?: string;
|
||||
stage?: string;
|
||||
latencyMs?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
error?: { message: string; stack?: string; code?: string };
|
||||
}
|
||||
|
||||
export interface LogsEntriesParams {
|
||||
|
||||
@@ -1,487 +1,25 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
ScrollText,
|
||||
TimerReset,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ErrorLogsMonitor } from '@/components/error-logs-monitor';
|
||||
import { LogsConfigCard } from '@/components/logs/logs-config-card';
|
||||
import { LogsDetailPanel } from '@/components/logs/logs-detail-panel';
|
||||
import { LogsEntryList } from '@/components/logs/logs-entry-list';
|
||||
import { LogsFilters } from '@/components/logs/logs-filters';
|
||||
import { LogsShell } from '@/components/logs/logs-shell';
|
||||
import { LogsPageSkeleton } from '@/components/logs/logs-page-skeleton';
|
||||
import { getSourceLabelMap, useLogsWorkspace, useUpdateLogsConfig } from '@/hooks/use-logs';
|
||||
// TODO i18n: import { useTranslation } from 'react-i18next'; when keys are ready
|
||||
|
||||
const DESKTOP_LOGS_BREAKPOINT = 1200;
|
||||
const LEFT_PANEL_WIDTH = 336;
|
||||
const RIGHT_PANEL_WIDTH = 368;
|
||||
const COLLAPSED_PANEL_WIDTH = 52;
|
||||
|
||||
function CollapsedPaneToggle({
|
||||
side,
|
||||
label,
|
||||
onExpand,
|
||||
}: {
|
||||
side: 'left' | 'right';
|
||||
label: string;
|
||||
onExpand: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col items-center justify-center gap-4 bg-muted/5',
|
||||
side === 'left' ? 'border-r border-border' : 'border-l border-border'
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onExpand}
|
||||
aria-label={`Show ${label.toLowerCase()}`}
|
||||
className="h-9 w-9 rounded-xl border border-border/70 bg-background/85 shadow-sm"
|
||||
>
|
||||
{side === 'left' ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<span
|
||||
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-foreground/45"
|
||||
style={{ writingMode: 'vertical-rl' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useLogsWorkspace, useUpdateLogsConfig } from '@/hooks/use-logs';
|
||||
|
||||
/**
|
||||
* Logs route — calm, virtualized 3-pane shell.
|
||||
*
|
||||
* Implementation lives in `LogsShell` (composition + layout) and the
|
||||
* `logs/*` components (filters/list/detail/settings). This file is just the
|
||||
* route + initial-load gate.
|
||||
*/
|
||||
export function LogsPage() {
|
||||
// TODO i18n: uncomment when keys for Syncing/Refresh and other strings are added
|
||||
// const { t } = useTranslation();
|
||||
const workspace = useLogsWorkspace();
|
||||
const updateConfig = useUpdateLogsConfig();
|
||||
const sourceLabels = getSourceLabelMap(workspace.sourcesQuery.data ?? []);
|
||||
const [isDesktopLayout, setIsDesktopLayout] = useState(() =>
|
||||
typeof window !== 'undefined' ? window.innerWidth >= DESKTOP_LOGS_BREAKPOINT : false
|
||||
);
|
||||
const [isFiltersCollapsed, setIsFiltersCollapsed] = useState(false);
|
||||
const [isDetailsCollapsed, setIsDetailsCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(`(min-width: ${DESKTOP_LOGS_BREAKPOINT}px)`);
|
||||
const syncLayout = () => {
|
||||
setIsDesktopLayout(window.innerWidth >= DESKTOP_LOGS_BREAKPOINT);
|
||||
};
|
||||
|
||||
syncLayout();
|
||||
mediaQuery.addEventListener('change', syncLayout);
|
||||
return () => mediaQuery.removeEventListener('change', syncLayout);
|
||||
}, []);
|
||||
|
||||
if (workspace.isInitialLoading) {
|
||||
return <LogsPageSkeleton />;
|
||||
}
|
||||
|
||||
const config = workspace.configQuery.data;
|
||||
if (!config) {
|
||||
if (!workspace.configQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-full flex-col overflow-hidden border-t border-border/40 bg-background font-sans text-foreground antialiased selection:bg-primary/30 selection:text-primary">
|
||||
<div className="pointer-events-none absolute inset-0 z-0 opacity-40 [background-image:radial-gradient(circle_at_1px_1px,rgba(38,38,36,0.08)_1px,transparent_0)] [background-size:14px_14px]" />
|
||||
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between border-b border-border/80 bg-card/95 px-6 py-2 backdrop-blur-xl shadow-md transition-all xl:px-8">
|
||||
<div className="flex items-center gap-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="group flex h-9 w-9 items-center justify-center rounded-xl border-2 border-primary/20 bg-primary/5 text-primary shadow-inner transition-all hover:scale-110 active:scale-90">
|
||||
<ScrollText className="h-4.5 w-4.5 transition-transform group-hover:rotate-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-primary-foreground shadow-lg shadow-primary/20">
|
||||
Operational
|
||||
</span>
|
||||
<h1 className="text-[17px] font-semibold tracking-tight text-foreground">
|
||||
Log Operations Center
|
||||
</h1>
|
||||
</div>
|
||||
<p className="font-mono text-[10px] font-medium uppercase tracking-[0.16em] text-foreground/45">
|
||||
CCS.TOC.LOGS.STREAM.v3
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden h-8 w-px bg-border/80 md:block" />
|
||||
|
||||
<div className="hidden items-center gap-10 md:flex">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'h-2 w-2 rounded-full ring-4 transition-all duration-700',
|
||||
config.redact
|
||||
? 'bg-emerald-500 shadow-[0_0_20px_rgba(16,185,129,0.6)] ring-emerald-500/30'
|
||||
: 'bg-zinc-600 ring-transparent'
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/90">
|
||||
Redaction
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-foreground/50">
|
||||
{config.redact ? 'Enforced' : 'Standby'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg border-2 border-border bg-muted shadow-inner">
|
||||
<TimerReset className="h-3.5 w-3.5 text-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/90">
|
||||
Retention
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-foreground/50">
|
||||
{config.retain_days}D / {config.rotate_mb}MB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="group h-9 gap-3 rounded-xl border-2 border-border bg-muted px-5 text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground transition-all hover:bg-foreground hover:text-background active:scale-95 shadow-lg shadow-black/5"
|
||||
onClick={() =>
|
||||
void Promise.all([workspace.sourcesQuery.refetch(), workspace.entriesQuery.refetch()])
|
||||
}
|
||||
>
|
||||
<div className="relative">
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 transition-transform duration-500',
|
||||
(workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching) &&
|
||||
'animate-spin'
|
||||
)}
|
||||
/>
|
||||
{!workspace.entriesQuery.isFetching && !workspace.sourcesQuery.isFetching && (
|
||||
<div className="absolute -right-1 -top-1 h-1.5 w-1.5 rounded-full border border-muted bg-primary animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
{workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching
|
||||
? /* TODO i18n: missing key for "Syncing" */ 'Syncing'
|
||||
: /* TODO i18n: missing key for "Refresh" */ 'Refresh'}
|
||||
</Button>
|
||||
<div className="h-7 w-px bg-border/80" />
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 w-9 rounded-xl border-2 border-border bg-muted p-0 text-foreground transition-all hover:bg-foreground hover:text-background active:scale-95 shadow-lg shadow-black/5"
|
||||
>
|
||||
<Link to="/health">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex min-h-0 flex-1 overflow-hidden">
|
||||
<Tabs defaultValue="stream" className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border/80 bg-card/80 px-6 py-2 backdrop-blur-xl shadow-inner xl:px-8">
|
||||
<TabsList className="h-10 w-auto gap-1.5 rounded-xl border border-border/60 bg-muted/40 p-1">
|
||||
<TabsTrigger
|
||||
value="stream"
|
||||
className="rounded-lg px-5 text-[11px] font-semibold uppercase tracking-[0.12em] text-foreground/60 transition-all data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-md"
|
||||
>
|
||||
Telemetry Stream
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="errors"
|
||||
className="rounded-lg px-5 text-[11px] font-semibold uppercase tracking-[0.12em] text-foreground/60 transition-all data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-md"
|
||||
>
|
||||
Legacy Errors
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="hidden items-center gap-3 lg:flex">
|
||||
<div className="flex items-center gap-2 rounded-full border border-border bg-muted px-3 py-1 shadow-inner">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.6)]"></span>
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-tight text-foreground/80">
|
||||
Connected
|
||||
</span>
|
||||
</div>
|
||||
<span className="pr-4 text-[11px] font-medium tabular-nums text-foreground/45">
|
||||
{workspace.entriesQuery.data?.length ?? 0} captured
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent
|
||||
value="stream"
|
||||
className="m-0 flex min-h-0 flex-1 overflow-y-auto lg:overflow-hidden focus-visible:outline-none"
|
||||
>
|
||||
{isDesktopLayout ? (
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<div
|
||||
data-logs-pane="filters"
|
||||
style={{ width: isFiltersCollapsed ? COLLAPSED_PANEL_WIDTH : LEFT_PANEL_WIDTH }}
|
||||
className="flex min-h-0 shrink-0 bg-muted/5"
|
||||
>
|
||||
{isFiltersCollapsed ? (
|
||||
<CollapsedPaneToggle
|
||||
side="left"
|
||||
label="Filters"
|
||||
onExpand={() => setIsFiltersCollapsed(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full min-h-0 w-full flex-col border-r border-border p-5 2xl:p-6">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/80">
|
||||
Filters
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
Search, source, and retention controls
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsFiltersCollapsed(true)}
|
||||
aria-label="Hide filters"
|
||||
className="h-9 w-9 rounded-xl border border-border/70 bg-background/85 shadow-sm"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1" data-logs-scroll-region="filters">
|
||||
<div className="space-y-5 pr-4">
|
||||
<LogsFilters
|
||||
sources={workspace.sourcesQuery.data ?? []}
|
||||
selectedSource={workspace.selectedSource}
|
||||
onSourceChange={workspace.setSelectedSource}
|
||||
selectedLevel={workspace.selectedLevel}
|
||||
onLevelChange={workspace.setSelectedLevel}
|
||||
search={workspace.search}
|
||||
onSearchChange={workspace.setSearch}
|
||||
limit={workspace.limit}
|
||||
onLimitChange={workspace.setLimit}
|
||||
onRefresh={() =>
|
||||
void Promise.all([
|
||||
workspace.sourcesQuery.refetch(),
|
||||
workspace.entriesQuery.refetch(),
|
||||
])
|
||||
}
|
||||
isRefreshing={
|
||||
workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching
|
||||
}
|
||||
/>
|
||||
<div className="border-t border-border/20 pt-5">
|
||||
<LogsConfigCard
|
||||
config={config}
|
||||
onSave={(payload) => updateConfig.mutate(payload)}
|
||||
isPending={updateConfig.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-logs-pane="entries"
|
||||
className="flex min-h-0 min-w-0 flex-1 overflow-hidden border-l border-r border-border bg-background/95"
|
||||
>
|
||||
<LogsEntryList
|
||||
entries={workspace.entriesQuery.data ?? []}
|
||||
selectedEntryId={workspace.selectedEntryId}
|
||||
onSelect={workspace.setSelectedEntryId}
|
||||
sourceLabels={sourceLabels}
|
||||
isLoading={workspace.entriesQuery.isLoading}
|
||||
isFetching={workspace.entriesQuery.isFetching}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-logs-pane="details"
|
||||
style={{ width: isDetailsCollapsed ? COLLAPSED_PANEL_WIDTH : RIGHT_PANEL_WIDTH }}
|
||||
className="flex min-h-0 shrink-0 bg-muted/5 shadow-inner"
|
||||
>
|
||||
{isDetailsCollapsed ? (
|
||||
<CollapsedPaneToggle
|
||||
side="right"
|
||||
label="Details"
|
||||
onExpand={() => setIsDetailsCollapsed(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full min-h-0 w-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border/50 bg-background/60 px-3 py-2">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/80">
|
||||
Details
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
Selected entry context and raw payload
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsDetailsCollapsed(true)}
|
||||
aria-label="Hide details"
|
||||
className="h-9 w-9 rounded-xl border border-border/70 bg-background/85 shadow-sm"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<LogsDetailPanel
|
||||
entry={workspace.selectedEntry}
|
||||
sourceLabel={
|
||||
workspace.selectedEntry
|
||||
? sourceLabels[workspace.selectedEntry.source]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="border-b border-border bg-muted/5 p-5">
|
||||
<div className="flex flex-col gap-6">
|
||||
<LogsFilters
|
||||
sources={workspace.sourcesQuery.data ?? []}
|
||||
selectedSource={workspace.selectedSource}
|
||||
onSourceChange={workspace.setSelectedSource}
|
||||
selectedLevel={workspace.selectedLevel}
|
||||
onLevelChange={workspace.setSelectedLevel}
|
||||
search={workspace.search}
|
||||
onSearchChange={workspace.setSearch}
|
||||
limit={workspace.limit}
|
||||
onLimitChange={workspace.setLimit}
|
||||
onRefresh={() =>
|
||||
void Promise.all([
|
||||
workspace.sourcesQuery.refetch(),
|
||||
workspace.entriesQuery.refetch(),
|
||||
])
|
||||
}
|
||||
isRefreshing={
|
||||
workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching
|
||||
}
|
||||
/>
|
||||
<LogsConfigCard
|
||||
config={config}
|
||||
onSave={(payload) => updateConfig.mutate(payload)}
|
||||
isPending={updateConfig.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[32rem] flex-col overflow-hidden border-b border-border bg-background/95">
|
||||
<LogsEntryList
|
||||
entries={workspace.entriesQuery.data ?? []}
|
||||
selectedEntryId={workspace.selectedEntryId}
|
||||
onSelect={workspace.setSelectedEntryId}
|
||||
sourceLabels={sourceLabels}
|
||||
isLoading={workspace.entriesQuery.isLoading}
|
||||
isFetching={workspace.entriesQuery.isFetching}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[30rem] flex-col overflow-hidden bg-muted/5 shadow-inner">
|
||||
<LogsDetailPanel
|
||||
entry={workspace.selectedEntry}
|
||||
sourceLabel={
|
||||
workspace.selectedEntry
|
||||
? sourceLabels[workspace.selectedEntry.source]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="errors"
|
||||
className="m-0 flex-1 overflow-y-auto bg-background/20 p-6 focus-visible:outline-none xl:p-8"
|
||||
>
|
||||
<div className="mx-auto max-w-5xl space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<div className="relative overflow-hidden rounded-[2.5rem] border-2 border-border bg-card/40 p-1.5 shadow-2xl shadow-black/10">
|
||||
<div className="absolute inset-0 opacity-[0.02] pointer-events-none [background-image:radial-gradient(circle_at_center,var(--primary)_1px,transparent_0)] [background-size:24px_24px]" />
|
||||
|
||||
<Card className="rounded-[calc(2.5rem-0.375rem)] border-none bg-background/60 shadow-none overflow-hidden backdrop-blur-md">
|
||||
<CardContent className="flex flex-col gap-6 p-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl border-2 border-primary/20 bg-primary/10 text-primary shadow-inner">
|
||||
<ScrollText className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.4em] text-primary">
|
||||
Legacy Diagnostic Node
|
||||
</p>
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground/40">
|
||||
CCS-MATRIX-FAILURE-MONITOR
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-border bg-background/50 px-3 py-1 text-[9px] font-black uppercase tracking-widest text-foreground/40 shadow-inner">
|
||||
Mode: Historical
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-3xl font-black uppercase tracking-tighter text-foreground">
|
||||
CLIProxy Failure Analysis
|
||||
</h2>
|
||||
<p className="max-w-3xl text-[15px] font-medium leading-relaxed text-muted-foreground/60">
|
||||
Maintain oversight of legacy request failures while the unified stream
|
||||
consolidates system-wide telemetry. This view provides direct access to the
|
||||
historical failure matrix for deep-field debugging.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-px w-full bg-gradient-to-r from-transparent via-border to-transparent opacity-40" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[2.5rem] border-2 border-border bg-muted/5 p-8 shadow-inner backdrop-blur-sm">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary shadow-[0_0_8px_rgba(var(--primary),0.5)]" />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-foreground/40">
|
||||
Realtime Monitoring Deck
|
||||
</span>
|
||||
</div>
|
||||
<ErrorLogsMonitor />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LogsShell workspace={workspace} updateConfig={updateConfig} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { LogsEntry } from '@/lib/api-client';
|
||||
import {
|
||||
deriveStageHint,
|
||||
deriveTraceGroups,
|
||||
type LeafItem,
|
||||
} from '@/components/logs/derive-trace-groups';
|
||||
import type { TraceGroup } from '@/components/logs/logs-trace-row';
|
||||
|
||||
// Minimal LogsEntry factory — fields the helpers actually look at.
|
||||
function entry(overrides: Partial<LogsEntry> & Pick<LogsEntry, 'id' | 'timestamp'>): LogsEntry {
|
||||
return {
|
||||
level: 'info',
|
||||
source: 'web-server:http',
|
||||
event: 'event.default',
|
||||
message: 'default message',
|
||||
...overrides,
|
||||
} as LogsEntry;
|
||||
}
|
||||
|
||||
function leafEntries(
|
||||
...overrides: Array<Partial<LogsEntry> & Pick<LogsEntry, 'id' | 'timestamp'>>
|
||||
) {
|
||||
return overrides.map(entry);
|
||||
}
|
||||
|
||||
describe('deriveStageHint', () => {
|
||||
it('returns explicit stage when present', () => {
|
||||
expect(deriveStageHint(entry({ id: '1', timestamp: 't', stage: 'route' }))).toBe('route');
|
||||
});
|
||||
|
||||
it('falls back to last `.`-segment of event when stage is missing', () => {
|
||||
expect(deriveStageHint(entry({ id: '1', timestamp: 't', event: 'request.dispatched' }))).toBe(
|
||||
'dispatched'
|
||||
);
|
||||
});
|
||||
|
||||
it('caps the derived hint at 12 chars', () => {
|
||||
expect(
|
||||
deriveStageHint(entry({ id: '1', timestamp: 't', event: 'foo.absurdlylongsegmentname' }))
|
||||
).toBe('absurdlylong');
|
||||
});
|
||||
|
||||
it('returns undefined when neither stage nor event is meaningful', () => {
|
||||
expect(deriveStageHint(entry({ id: '1', timestamp: 't', event: '' }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats single-segment event as its own hint', () => {
|
||||
expect(deriveStageHint(entry({ id: '1', timestamp: 't', event: 'startup' }))).toBe('startup');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveTraceGroups', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(deriveTraceGroups([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits a single leaf for a no-requestId entry', () => {
|
||||
const e = entry({ id: '1', timestamp: '2026-01-01T00:00:00Z' });
|
||||
const result = deriveTraceGroups([e]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.kind).toBe('leaf');
|
||||
expect((result[0] as LeafItem).entry).toBe(e);
|
||||
expect((result[0] as LeafItem).repeatCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it('groups entries sharing a requestId into one trace', () => {
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '1', timestamp: '2026-01-01T00:00:01Z', requestId: 'req-1', stage: 'intake' },
|
||||
{ id: '2', timestamp: '2026-01-01T00:00:02Z', requestId: 'req-1', stage: 'route' }
|
||||
)
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.kind).toBe('trace');
|
||||
expect((result[0] as TraceGroup).children).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('sorts trace children by ts ascending and pins group ts to oldest child', () => {
|
||||
// Input order intentionally reverses ts so we can assert the sort.
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '2', timestamp: '2026-01-01T00:00:02Z', requestId: 'req-1' },
|
||||
{ id: '1', timestamp: '2026-01-01T00:00:01Z', requestId: 'req-1' }
|
||||
)
|
||||
);
|
||||
const trace = result[0] as TraceGroup;
|
||||
expect(trace.children.map((c) => c.id)).toEqual(['1', '2']);
|
||||
expect(trace.ts).toBe('2026-01-01T00:00:01Z');
|
||||
});
|
||||
|
||||
it('coalesces two adjacent identical leaves into a single ×N row', () => {
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '1', timestamp: 't1', event: 'poll', message: 'tick' },
|
||||
{ id: '2', timestamp: 't2', event: 'poll', message: 'tick' }
|
||||
)
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
const leaf = result[0] as LeafItem;
|
||||
expect(leaf.repeatCount).toBe(2);
|
||||
expect(leaf.collapsedRange?.fromTs).toBe('t1');
|
||||
expect(leaf.collapsedRange?.toTs).toBe('t2');
|
||||
});
|
||||
|
||||
it('does NOT coalesce identical leaves split by an unrelated trace (round-3 fix)', () => {
|
||||
// Two identical no-requestId entries with a trace between them: the run
|
||||
// must break — they were not adjacent in the real stream.
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '1', timestamp: 't1', event: 'poll', message: 'tick' },
|
||||
{ id: '2', timestamp: 't2', requestId: 'req-1' },
|
||||
{ id: '3', timestamp: 't3', event: 'poll', message: 'tick' }
|
||||
)
|
||||
);
|
||||
// Expected: 1 trace + 2 distinct leaves (no ×N).
|
||||
const leaves = result.filter((r) => r.kind === 'leaf') as LeafItem[];
|
||||
expect(leaves).toHaveLength(2);
|
||||
expect(leaves.every((l) => l.repeatCount === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps adjacent leaves with different stages as separate rows (round-6 fix)', () => {
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '1', timestamp: 't1', event: 'e', message: 'm', stage: 'route' },
|
||||
{ id: '2', timestamp: 't2', event: 'e', message: 'm', stage: 'dispatch' }
|
||||
)
|
||||
);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((r) => r.kind === 'leaf' && r.repeatCount === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps adjacent leaves with different messages as separate rows', () => {
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '1', timestamp: 't1', event: 'login', message: 'alice' },
|
||||
{ id: '2', timestamp: 't2', event: 'login', message: 'bob' }
|
||||
)
|
||||
);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((r) => r.kind === 'leaf' && r.repeatCount === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('display-sorts items reverse-chronologically', () => {
|
||||
// Use distinct events so leaves don't coalesce — testing display sort,
|
||||
// not coalesce.
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '1', timestamp: '2026-01-01T00:00:01Z', event: 'a' },
|
||||
{ id: '2', timestamp: '2026-01-01T00:00:03Z', event: 'b' },
|
||||
{ id: '3', timestamp: '2026-01-01T00:00:02Z', event: 'c' }
|
||||
)
|
||||
);
|
||||
const ids = result.map((r) => (r.kind === 'leaf' ? r.entry.id : 'trace'));
|
||||
expect(ids).toEqual(['2', '3', '1']);
|
||||
});
|
||||
|
||||
it('computes max level across trace children', () => {
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '1', timestamp: 't1', requestId: 'r', level: 'debug' },
|
||||
{ id: '2', timestamp: 't2', requestId: 'r', level: 'error' },
|
||||
{ id: '3', timestamp: 't3', requestId: 'r', level: 'info' }
|
||||
)
|
||||
);
|
||||
expect((result[0] as TraceGroup).maxLevel).toBe('error');
|
||||
});
|
||||
|
||||
it('sums latencyMs across trace children', () => {
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '1', timestamp: 't1', requestId: 'r', latencyMs: 100 },
|
||||
{ id: '2', timestamp: 't2', requestId: 'r', latencyMs: 50 },
|
||||
{ id: '3', timestamp: 't3', requestId: 'r' /* no latency */ }
|
||||
)
|
||||
);
|
||||
expect((result[0] as TraceGroup).totalLatencyMs).toBe(150);
|
||||
});
|
||||
|
||||
it('preserves original input order when computing leaf adjacency, even if display sort reorders later', () => {
|
||||
// Stream: [A, B, A] — A entries identical but split by B. A run of 1+1+1.
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '1', timestamp: '2026-01-01T00:00:01Z', event: 'A' },
|
||||
{ id: '2', timestamp: '2026-01-01T00:00:02Z', event: 'B' },
|
||||
{ id: '3', timestamp: '2026-01-01T00:00:03Z', event: 'A' }
|
||||
)
|
||||
);
|
||||
const leaves = result.filter((r) => r.kind === 'leaf') as LeafItem[];
|
||||
expect(leaves).toHaveLength(3);
|
||||
// None should have repeatCount because A and A weren't adjacent.
|
||||
expect(leaves.every((l) => l.repeatCount === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,26 @@
|
||||
import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
|
||||
import { render, screen, userEvent, waitFor, within } from '@tests/setup/test-utils';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock react-virtuoso for jsdom (no layout measurements -> zero rows).
|
||||
vi.mock('react-virtuoso', () => {
|
||||
const Virtuoso = ({
|
||||
data,
|
||||
itemContent,
|
||||
}: {
|
||||
data: unknown[];
|
||||
itemContent: (index: number, item: unknown) => React.ReactNode;
|
||||
}) => (
|
||||
<div data-virtuoso-mock>
|
||||
{data.map((item, index) => (
|
||||
<div key={index} data-virtuoso-row={index}>
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return { Virtuoso };
|
||||
});
|
||||
|
||||
import { LogsPage } from '@/pages/logs';
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
@@ -8,15 +29,12 @@ beforeAll(() => {
|
||||
if (!Element.prototype.hasPointerCapture) {
|
||||
Element.prototype.hasPointerCapture = () => false;
|
||||
}
|
||||
|
||||
if (!Element.prototype.setPointerCapture) {
|
||||
Element.prototype.setPointerCapture = () => undefined;
|
||||
}
|
||||
|
||||
if (!Element.prototype.releasePointerCapture) {
|
||||
Element.prototype.releasePointerCapture = () => undefined;
|
||||
}
|
||||
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => undefined;
|
||||
}
|
||||
@@ -47,7 +65,6 @@ function buildEntries(source: string) {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'entry-1',
|
||||
@@ -92,7 +109,6 @@ function installFetchMock() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.pathname === '/api/logs/sources') {
|
||||
return jsonResponse({
|
||||
sources: [
|
||||
@@ -113,7 +129,6 @@ function installFetchMock() {
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.pathname === '/api/logs/entries') {
|
||||
const source = parsed.searchParams.get('source') ?? 'all';
|
||||
const search = parsed.searchParams.get('search');
|
||||
@@ -122,7 +137,6 @@ function installFetchMock() {
|
||||
);
|
||||
return jsonResponse({ entries });
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unhandled request: ${url}`));
|
||||
});
|
||||
}
|
||||
@@ -131,69 +145,55 @@ describe('LogsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = fetchMock;
|
||||
// Force desktop layout so the detail panel renders inline.
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 900,
|
||||
value: 1400,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the loading skeleton while the initial queries are pending', () => {
|
||||
fetchMock.mockImplementation(() => new Promise<Response>(() => {}));
|
||||
|
||||
render(<LogsPage />);
|
||||
|
||||
expect(screen.getByLabelText('Loading logs...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters by source and search query', async () => {
|
||||
it('renders entries and supports search filtering', async () => {
|
||||
installFetchMock();
|
||||
|
||||
render(<LogsPage />);
|
||||
|
||||
expect(
|
||||
(await screen.findAllByText('Boot sequence failed for dashboard logging')).length
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Agent Runner' }));
|
||||
|
||||
expect((await screen.findAllByText('Worker retry scheduled')).length).toBeGreaterThan(0);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByText('Boot sequence failed for dashboard logging')).toHaveLength(0);
|
||||
});
|
||||
|
||||
await userEvent.clear(screen.getByLabelText('Search'));
|
||||
await userEvent.type(screen.getByLabelText('Search'), 'retry');
|
||||
const search = screen.getByLabelText('Search');
|
||||
await userEvent.clear(search);
|
||||
await userEvent.type(search, 'retry');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some((call) =>
|
||||
String(call[0]).includes('/api/logs/entries?source=agent-runner')
|
||||
)
|
||||
).toBe(true);
|
||||
expect(fetchMock.mock.calls.some((call) => String(call[0]).includes('search=retry'))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the selected entry detail and raw context', async () => {
|
||||
it('selecting a row reveals overview fields and raw JSON', async () => {
|
||||
installFetchMock();
|
||||
|
||||
render(<LogsPage />);
|
||||
|
||||
expect(
|
||||
(await screen.findAllByText('Boot sequence failed for dashboard logging')).length
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Worker retry scheduled/i }));
|
||||
// Wait for entries to load, then click the row containing the warn entry.
|
||||
await screen.findAllByText('Worker retry scheduled');
|
||||
const row = screen
|
||||
.getAllByRole('row')
|
||||
.find((el) => within(el).queryByText(/Worker retry scheduled/));
|
||||
if (!row) throw new Error('row not found');
|
||||
await userEvent.click(row);
|
||||
|
||||
// Detail panel header shows event name.
|
||||
expect((await screen.findAllByText('task.retry')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('4121').length).toBeGreaterThan(0);
|
||||
|
||||
await userEvent.click(screen.getByRole('tab', { name: /Raw context/i }));
|
||||
|
||||
expect(await screen.findByText(/network jitter/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/run-2/)).toBeInTheDocument();
|
||||
// Verify detail panel surfaced the selected entry's event name (header).
|
||||
expect(screen.getAllByText('task.retry').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||