devbrief 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {devbrief-0.2.0 → devbrief-0.3.0}/CLAUDE.md +1 -1
  2. {devbrief-0.2.0 → devbrief-0.3.0}/PKG-INFO +46 -3
  3. {devbrief-0.2.0 → devbrief-0.3.0}/README.md +42 -2
  4. {devbrief-0.2.0 → devbrief-0.3.0}/pyproject.toml +4 -1
  5. {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/cli.py +2 -0
  6. devbrief-0.3.0/src/devbrief/commands/logs.py +421 -0
  7. devbrief-0.3.0/src/devbrief/templates/base.html +147 -0
  8. devbrief-0.3.0/src/devbrief/templates/logs/dashboard.html +152 -0
  9. devbrief-0.3.0/tests/test_logs.py +331 -0
  10. {devbrief-0.2.0 → devbrief-0.3.0}/uv.lock +124 -1
  11. {devbrief-0.2.0 → devbrief-0.3.0}/.github/workflows/ci.yml +0 -0
  12. {devbrief-0.2.0 → devbrief-0.3.0}/.github/workflows/release.yml +0 -0
  13. {devbrief-0.2.0 → devbrief-0.3.0}/.gitignore +0 -0
  14. {devbrief-0.2.0 → devbrief-0.3.0}/.python-version +0 -0
  15. {devbrief-0.2.0 → devbrief-0.3.0}/LICENSE +0 -0
  16. {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/__init__.py +0 -0
  17. {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/brief.py +0 -0
  18. {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/commands/__init__.py +0 -0
  19. {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/commands/auth.py +0 -0
  20. {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/commands/repo.py +0 -0
  21. {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/core/__init__.py +0 -0
  22. {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/core/config.py +0 -0
  23. {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/core/credentials.py +0 -0
  24. {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/display.py +0 -0
  25. {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/github.py +0 -0
  26. {devbrief-0.2.0 → devbrief-0.3.0}/tests/__init__.py +0 -0
  27. {devbrief-0.2.0 → devbrief-0.3.0}/tests/test_credentials.py +0 -0
  28. {devbrief-0.2.0 → devbrief-0.3.0}/tests/test_display.py +0 -0
  29. {devbrief-0.2.0 → devbrief-0.3.0}/tests/test_github.py +0 -0
@@ -66,7 +66,7 @@ devbrief/
66
66
  |-----------------|-------------|------------------------------------------------|
67
67
  | devbrief repo | LIVE | v0.2.0, Typer, credentials via resolve_api_key/resolve_model |
68
68
  | devbrief auth | LIVE | v0.2.0, key validation, config write/read/clear, 600 perms |
69
- | devbrief logs | BUILD NEXT | CloudWatch / log stream fetcher |
69
+ | devbrief logs | LIVE | v0.3.0, FastAPI+HTMX polling dashboard, ring buffer, file (1s tail)/stdin |
70
70
  | devbrief env | PLANNED | Rust entry point via maturin/PyO3 |
71
71
  | devbrief api | PLANNED | |
72
72
  | devbrief infra | PLANNED | |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devbrief
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Generate a human-readable brief for any GitHub repository using Claude AI
5
5
  Project-URL: Homepage, https://github.com/s3bc40/devbrief
6
6
  Project-URL: Repository, https://github.com/s3bc40/devbrief
@@ -12,17 +12,23 @@ Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Requires-Python: >=3.12
14
14
  Requires-Dist: anthropic>=0.84.0
15
+ Requires-Dist: fastapi>=0.115.0
16
+ Requires-Dist: jinja2>=3.1.0
15
17
  Requires-Dist: python-dotenv>=1.2.2
16
18
  Requires-Dist: requests>=2.32.5
17
19
  Requires-Dist: rich>=14.3.3
18
20
  Requires-Dist: typer>=0.15.0
21
+ Requires-Dist: uvicorn>=0.30.0
19
22
  Description-Content-Type: text/markdown
20
23
 
21
24
  # devbrief
22
25
 
23
26
  > Project situational awareness.
24
27
 
25
- `devbrief` takes a GitHub URL, pulls repository metadata, README, and file tree, then asks Claude to produce a structured brief — covering what the project does, its tech stack, how to get started, and its limitations — directly in your terminal.
28
+ `devbrief` is a developer CLI for rapid project situational awareness:
29
+
30
+ - **`devbrief repo`** — takes a GitHub URL, pulls repository metadata, README, and file tree, then asks Claude to produce a structured brief directly in your terminal.
31
+ - **`devbrief logs`** — streams a log file (or stdin) into a local browser dashboard with live filtering, level highlighting, and rolling metrics.
26
32
 
27
33
  ---
28
34
 
@@ -84,6 +90,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
84
90
  ╭─ Commands ───────────────────────────────────────────────────────────────────╮
85
91
  │ repo Analyze a GitHub repository. │
86
92
  │ auth Manage API credentials. │
93
+ │ logs Stream logs into a live dashboard. │
87
94
  ╰──────────────────────────────────────────────────────────────────────────────╯
88
95
  ```
89
96
 
@@ -117,6 +124,36 @@ devbrief auth --show # display masked stored key
117
124
  devbrief auth --clear # remove stored key
118
125
  ```
119
126
 
127
+ ### devbrief logs
128
+
129
+ ```bash
130
+ devbrief logs [FILE] [--port PORT] [--no-browser]
131
+ ```
132
+
133
+ Opens a local browser dashboard at `http://127.0.0.1:7890` (default port).
134
+
135
+ **Examples:**
136
+
137
+ ```bash
138
+ # Visualise a log file
139
+ devbrief logs /var/log/app.log
140
+
141
+ # Pipe from a running process
142
+ your-app 2>&1 | devbrief logs
143
+
144
+ # Use a custom port
145
+ devbrief logs /var/log/app.log --port 8080
146
+ ```
147
+
148
+ | Option | Description |
149
+ |---|---|
150
+ | `FILE` | Path to a log file. Omit to read from stdin. |
151
+ | `--port PORT` | Dashboard port (default: `7890`) |
152
+ | `--no-browser` | Do not open the browser automatically |
153
+ | `--help` | Show usage and exit |
154
+
155
+ The dashboard auto-detects common log formats (JSON structured logs, ISO timestamp prefix, `[LEVEL]`, `LEVEL:`) and supports live client-side filtering by level, keyword, and time range. New lines appended to the file appear within ~3 seconds.
156
+
120
157
  ---
121
158
 
122
159
  ## Credential resolution order
@@ -200,7 +237,12 @@ src/devbrief/
200
237
  ├── cli.py # Typer app — registers all subcommands
201
238
  ├── commands/
202
239
  │ ├── repo.py # devbrief repo
203
- └── auth.py # devbrief auth
240
+ ├── auth.py # devbrief auth
241
+ │ └── logs.py # devbrief logs — FastAPI server, log parser, ring buffer
242
+ ├── templates/
243
+ │ ├── base.html # Base HTML layout (HTMX)
244
+ │ └── logs/
245
+ │ └── dashboard.html # Log dashboard template
204
246
  ├── core/
205
247
  │ ├── credentials.py # API key + model resolution chain
206
248
  │ └── config.py # Config file read/write (~/.config/devbrief/config.toml)
@@ -209,6 +251,7 @@ src/devbrief/
209
251
  └── display.py # Rich terminal rendering
210
252
  tests/
211
253
  ├── test_credentials.py # Credential resolution + auth command tests
254
+ ├── test_logs.py # Log parser, ring buffer, polling endpoints
212
255
  ├── test_github.py
213
256
  └── test_display.py
214
257
  ```
@@ -2,7 +2,10 @@
2
2
 
3
3
  > Project situational awareness.
4
4
 
5
- `devbrief` takes a GitHub URL, pulls repository metadata, README, and file tree, then asks Claude to produce a structured brief — covering what the project does, its tech stack, how to get started, and its limitations — directly in your terminal.
5
+ `devbrief` is a developer CLI for rapid project situational awareness:
6
+
7
+ - **`devbrief repo`** — takes a GitHub URL, pulls repository metadata, README, and file tree, then asks Claude to produce a structured brief directly in your terminal.
8
+ - **`devbrief logs`** — streams a log file (or stdin) into a local browser dashboard with live filtering, level highlighting, and rolling metrics.
6
9
 
7
10
  ---
8
11
 
@@ -64,6 +67,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
64
67
  ╭─ Commands ───────────────────────────────────────────────────────────────────╮
65
68
  │ repo Analyze a GitHub repository. │
66
69
  │ auth Manage API credentials. │
70
+ │ logs Stream logs into a live dashboard. │
67
71
  ╰──────────────────────────────────────────────────────────────────────────────╯
68
72
  ```
69
73
 
@@ -97,6 +101,36 @@ devbrief auth --show # display masked stored key
97
101
  devbrief auth --clear # remove stored key
98
102
  ```
99
103
 
104
+ ### devbrief logs
105
+
106
+ ```bash
107
+ devbrief logs [FILE] [--port PORT] [--no-browser]
108
+ ```
109
+
110
+ Opens a local browser dashboard at `http://127.0.0.1:7890` (default port).
111
+
112
+ **Examples:**
113
+
114
+ ```bash
115
+ # Visualise a log file
116
+ devbrief logs /var/log/app.log
117
+
118
+ # Pipe from a running process
119
+ your-app 2>&1 | devbrief logs
120
+
121
+ # Use a custom port
122
+ devbrief logs /var/log/app.log --port 8080
123
+ ```
124
+
125
+ | Option | Description |
126
+ |---|---|
127
+ | `FILE` | Path to a log file. Omit to read from stdin. |
128
+ | `--port PORT` | Dashboard port (default: `7890`) |
129
+ | `--no-browser` | Do not open the browser automatically |
130
+ | `--help` | Show usage and exit |
131
+
132
+ The dashboard auto-detects common log formats (JSON structured logs, ISO timestamp prefix, `[LEVEL]`, `LEVEL:`) and supports live client-side filtering by level, keyword, and time range. New lines appended to the file appear within ~3 seconds.
133
+
100
134
  ---
101
135
 
102
136
  ## Credential resolution order
@@ -180,7 +214,12 @@ src/devbrief/
180
214
  ├── cli.py # Typer app — registers all subcommands
181
215
  ├── commands/
182
216
  │ ├── repo.py # devbrief repo
183
- └── auth.py # devbrief auth
217
+ ├── auth.py # devbrief auth
218
+ │ └── logs.py # devbrief logs — FastAPI server, log parser, ring buffer
219
+ ├── templates/
220
+ │ ├── base.html # Base HTML layout (HTMX)
221
+ │ └── logs/
222
+ │ └── dashboard.html # Log dashboard template
184
223
  ├── core/
185
224
  │ ├── credentials.py # API key + model resolution chain
186
225
  │ └── config.py # Config file read/write (~/.config/devbrief/config.toml)
@@ -189,6 +228,7 @@ src/devbrief/
189
228
  └── display.py # Rich terminal rendering
190
229
  tests/
191
230
  ├── test_credentials.py # Credential resolution + auth command tests
231
+ ├── test_logs.py # Log parser, ring buffer, polling endpoints
192
232
  ├── test_github.py
193
233
  └── test_display.py
194
234
  ```
@@ -1,15 +1,18 @@
1
1
  [project]
2
2
  name = "devbrief"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Generate a human-readable brief for any GitHub repository using Claude AI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
7
7
  dependencies = [
8
8
  "anthropic>=0.84.0",
9
+ "fastapi>=0.115.0",
10
+ "jinja2>=3.1.0",
9
11
  "python-dotenv>=1.2.2",
10
12
  "requests>=2.32.5",
11
13
  "rich>=14.3.3",
12
14
  "typer>=0.15.0",
15
+ "uvicorn>=0.30.0",
13
16
  ]
14
17
  license = { text = "MIT" }
15
18
  authors = [
@@ -2,6 +2,7 @@ from dotenv import load_dotenv
2
2
  import typer
3
3
 
4
4
  from devbrief.commands.auth import auth_command
5
+ from devbrief.commands.logs import logs_command
5
6
  from devbrief.commands.repo import repo_command
6
7
 
7
8
  load_dotenv()
@@ -10,3 +11,4 @@ app = typer.Typer(help="Project situational awareness.")
10
11
 
11
12
  app.command("repo")(repo_command)
12
13
  app.command("auth")(auth_command)
14
+ app.command("logs")(logs_command)
@@ -0,0 +1,421 @@
1
+ """devbrief logs — live log dashboard via FastAPI + HTMX polling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import html as _html
7
+ import json
8
+ import re
9
+ import sys
10
+ import time as _time
11
+ import webbrowser
12
+ from collections import deque
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from typing import Annotated
16
+
17
+ import typer
18
+ import uvicorn
19
+ from fastapi import FastAPI
20
+ from fastapi.responses import HTMLResponse
21
+ from fastapi.templating import Jinja2Templates
22
+ from starlette.requests import Request
23
+
24
+ from devbrief.display import console
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Constants
28
+ # ---------------------------------------------------------------------------
29
+
30
+ RING_BUFFER_SIZE = 10_000
31
+
32
+ _LEVEL_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
33
+ # ISO-ish timestamp prefix: 2024-01-01 12:00:00,123 ERROR msg
34
+ (
35
+ "level",
36
+ re.compile(
37
+ r"(?P<ts>\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?)"
38
+ r"\s+(?P<level>ERROR|WARN(?:ING)?|INFO|DEBUG)\s+(?P<msg>.+)",
39
+ re.IGNORECASE,
40
+ ),
41
+ ),
42
+ # [LEVEL] msg
43
+ (
44
+ "bracket",
45
+ re.compile(
46
+ r"\[(?P<level>ERROR|WARN(?:ING)?|INFO|DEBUG)\]\s*(?P<msg>.+)",
47
+ re.IGNORECASE,
48
+ ),
49
+ ),
50
+ # LEVEL: msg
51
+ (
52
+ "prefix",
53
+ re.compile(
54
+ r"^(?P<level>ERROR|WARN(?:ING)?|INFO|DEBUG):\s*(?P<msg>.+)",
55
+ re.IGNORECASE,
56
+ ),
57
+ ),
58
+ ]
59
+
60
+ _LEVEL_ALIASES = {
61
+ "WARNING": "WARN",
62
+ }
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Log entry model (plain dataclass — no Pydantic needed)
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ class LogEntry:
71
+ __slots__ = ("timestamp", "level", "message", "raw")
72
+
73
+ def __init__(self, timestamp: str, level: str, message: str, raw: str) -> None:
74
+ self.timestamp = timestamp
75
+ self.level = level
76
+ self.message = message
77
+ self.raw = raw
78
+
79
+ def to_dict(self) -> dict[str, str]:
80
+ return {
81
+ "timestamp": self.timestamp,
82
+ "level": self.level,
83
+ "message": self.message,
84
+ "raw": self.raw,
85
+ }
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Log parser
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ def _now_iso() -> str:
94
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
95
+
96
+
97
+ def parse_log_line(line: str) -> LogEntry:
98
+ """Parse a single log line into a LogEntry.
99
+
100
+ Resolution order:
101
+ 1. Structured JSON (first valid JSON object wins)
102
+ 2. Plaintext regex (common log formats)
103
+ 3. UNKNOWN — raw line
104
+ """
105
+ stripped = line.strip()
106
+ if not stripped:
107
+ return LogEntry(_now_iso(), "UNKNOWN", stripped, stripped)
108
+
109
+ # 1. JSON
110
+ if stripped.startswith("{"):
111
+ try:
112
+ obj = json.loads(stripped)
113
+ level = str(
114
+ obj.get("level")
115
+ or obj.get("levelname")
116
+ or obj.get("severity")
117
+ or "UNKNOWN"
118
+ ).upper()
119
+ level = _LEVEL_ALIASES.get(level, level)
120
+ ts = str(
121
+ obj.get("timestamp") or obj.get("time") or obj.get("ts") or _now_iso()
122
+ )
123
+ msg = str(
124
+ obj.get("message") or obj.get("msg") or obj.get("text") or stripped
125
+ )
126
+ return LogEntry(ts, level, msg, stripped)
127
+ except (json.JSONDecodeError, ValueError):
128
+ pass
129
+
130
+ # 2. Regex
131
+ for _, pattern in _LEVEL_PATTERNS:
132
+ m = pattern.match(stripped)
133
+ if m:
134
+ gd = m.groupdict()
135
+ level = gd["level"].upper()
136
+ level = _LEVEL_ALIASES.get(level, level)
137
+ ts = gd.get("ts") or _now_iso()
138
+ msg = gd["msg"].strip()
139
+ return LogEntry(ts, level, msg, stripped)
140
+
141
+ # 3. Unknown
142
+ return LogEntry(_now_iso(), "UNKNOWN", stripped, stripped)
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Ring buffer
147
+ # ---------------------------------------------------------------------------
148
+
149
+
150
+ class LogBuffer:
151
+ def __init__(self, maxlen: int = RING_BUFFER_SIZE) -> None:
152
+ self._buf: deque[LogEntry] = deque(maxlen=maxlen)
153
+ self._start: float | None = None
154
+ self._total: int = 0 # total ever appended (not capped by maxlen)
155
+
156
+ def append(self, entry: LogEntry) -> None:
157
+ if self._start is None:
158
+ self._start = _time.monotonic()
159
+ self._total += 1
160
+ self._buf.append(entry)
161
+
162
+ @property
163
+ def entries(self) -> list[LogEntry]:
164
+ return list(self._buf)
165
+
166
+ @property
167
+ def total(self) -> int:
168
+ """Absolute count of entries ever appended (monotonically increasing)."""
169
+ return self._total
170
+
171
+ def since(self, after: int) -> list[LogEntry]:
172
+ """Return entries with absolute index > after.
173
+
174
+ 'after' is an absolute insertion count (from .total), not a ring
175
+ position. Entries already evicted from the ring are silently skipped.
176
+ """
177
+ if after >= self._total:
178
+ return []
179
+ buf = list(self._buf)
180
+ if not buf:
181
+ return []
182
+ buf_start = self._total - len(buf) # absolute index of buf[0]
183
+ start_pos = max(0, after - buf_start)
184
+ return buf[start_pos:]
185
+
186
+ @property
187
+ def rate_per_sec(self) -> float:
188
+ if self._start is None or self._total == 0:
189
+ return 0.0
190
+ elapsed = _time.monotonic() - self._start
191
+ return self._total / elapsed if elapsed > 0 else 0.0
192
+
193
+ def __len__(self) -> int:
194
+ return len(self._buf)
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # Shared state (module-level singletons per server process)
199
+ # ---------------------------------------------------------------------------
200
+
201
+ _buffer = LogBuffer()
202
+
203
+ # Tracks (monotonic_ingest_time, level) for the metrics 5-min window.
204
+ # Uses ingest time, NOT log-file timestamps, so old log files work correctly.
205
+ _recent: deque[tuple[float, str]] = deque(maxlen=RING_BUFFER_SIZE)
206
+
207
+
208
+ def _append_entry(entry: LogEntry) -> None:
209
+ """Append to the ring buffer and record ingest time for metrics."""
210
+ _buffer.append(entry)
211
+ _recent.append((_time.monotonic(), entry.level))
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Log ingestion helpers
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ async def _ingest_file(path: Path) -> None:
220
+ """Read a log file into the ring buffer, then poll for new lines every 1 s.
221
+
222
+ The 1-second sleep loop is a dependency-free replacement for watchfiles:
223
+ after the initial read the file handle is kept open so readline() picks up
224
+ any bytes appended after startup without reopening the file descriptor.
225
+ """
226
+ with path.open("r", encoding="utf-8", errors="replace") as fh:
227
+ while line := fh.readline():
228
+ _append_entry(parse_log_line(line))
229
+ while True:
230
+ await asyncio.sleep(1)
231
+ while line := fh.readline():
232
+ _append_entry(parse_log_line(line))
233
+
234
+
235
+ async def _ingest_stdin() -> None:
236
+ """Read stdin line-by-line in a thread executor to avoid blocking."""
237
+ loop = asyncio.get_running_loop()
238
+
239
+ def _read_all() -> list[str]:
240
+ return sys.stdin.read().splitlines()
241
+
242
+ lines = await loop.run_in_executor(None, _read_all)
243
+ for line in lines:
244
+ _append_entry(parse_log_line(line))
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # FastAPI app factory
249
+ # ---------------------------------------------------------------------------
250
+
251
+ _TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
252
+
253
+
254
+ def _build_app() -> FastAPI:
255
+ app = FastAPI(title="devbrief logs")
256
+ templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
257
+
258
+ @app.get("/", response_class=HTMLResponse)
259
+ async def dashboard(request: Request) -> HTMLResponse:
260
+ entries = _buffer.entries
261
+ return templates.TemplateResponse(
262
+ "logs/dashboard.html",
263
+ {
264
+ "request": request,
265
+ "entries": entries,
266
+ "metrics": _compute_metrics(),
267
+ "total": _buffer.total,
268
+ },
269
+ )
270
+
271
+ @app.get("/rows", response_class=HTMLResponse)
272
+ async def get_rows(after: int = 0) -> HTMLResponse:
273
+ entries = _buffer.since(after)
274
+ new_after = _buffer.total
275
+ rows_html = "".join(_render_row(e) for e in entries)
276
+ # OOB-swap only the cursor input (a plain data element — no triggers).
277
+ # The polling div (#row-poll) is never replaced, so its every-2s timer
278
+ # runs stably and always picks up the current value of #poll-cursor via
279
+ # hx-include. Replacing the polling element itself via OOB can leave
280
+ # the old setTimeout running on the detached node, preventing the new
281
+ # element's timer from starting and freezing the after= cursor at 0.
282
+ cursor = (
283
+ f'<input type="hidden" id="poll-cursor" name="after"'
284
+ f' value="{new_after}" hx-swap-oob="true">'
285
+ )
286
+ return HTMLResponse(rows_html + cursor)
287
+
288
+ @app.get("/metrics", response_class=HTMLResponse)
289
+ async def get_metrics() -> HTMLResponse:
290
+ return HTMLResponse(_render_metrics())
291
+
292
+ @app.get("/entries")
293
+ async def get_entries() -> list[dict[str, str]]:
294
+ return [e.to_dict() for e in _buffer.entries]
295
+
296
+ return app
297
+
298
+
299
+ def _render_row(entry: LogEntry) -> str:
300
+ """Render a single log entry as a one-line HTML <tr> fragment."""
301
+ ts = _html.escape(entry.timestamp)
302
+ level = _html.escape(entry.level)
303
+ msg = _html.escape(entry.message)
304
+ return (
305
+ f'<tr class="log-row" data-level="{level}" data-ts="{ts}" data-msg="{msg.lower()}">'
306
+ f'<td class="ts">{ts}</td>'
307
+ f'<td class="level-cell level-{level}">{level}</td>'
308
+ f"<td>{msg}</td>"
309
+ f"</tr>"
310
+ )
311
+
312
+
313
+ def _compute_metrics() -> dict[str, str]:
314
+ """Compute current metric values.
315
+
316
+ The 5-min window uses monotonic INGEST time from _recent, not the log-file
317
+ timestamps stored in LogEntry. Log files typically have old timestamps so
318
+ comparing them to datetime.now() would always return zero errors/warnings.
319
+ """
320
+ total = len(_buffer)
321
+ cutoff = _time.monotonic() - 5 * 60
322
+ errors5 = sum(1 for t, lvl in _recent if t >= cutoff and lvl == "ERROR")
323
+ warns5 = sum(1 for t, lvl in _recent if t >= cutoff and lvl == "WARN")
324
+ return {
325
+ "total": str(total),
326
+ "errors5": str(errors5),
327
+ "warns5": str(warns5),
328
+ "rate": f"{_buffer.rate_per_sec:.1f}",
329
+ }
330
+
331
+
332
+ def _render_metrics() -> str:
333
+ """Render the inner HTML of the metrics bar as a single-line string."""
334
+ m = _compute_metrics()
335
+ return (
336
+ f'<div class="metric"><span class="label">Total entries</span>'
337
+ f'<span class="value">{m["total"]}</span></div>'
338
+ f'<div class="metric"><span class="label">Errors / 5 min</span>'
339
+ f'<span class="value error">{m["errors5"]}</span></div>'
340
+ f'<div class="metric"><span class="label">Warnings / 5 min</span>'
341
+ f'<span class="value warn">{m["warns5"]}</span></div>'
342
+ f'<div class="metric"><span class="label">Entries / sec</span>'
343
+ f'<span class="value info">{m["rate"]}</span></div>'
344
+ )
345
+
346
+
347
+ # ---------------------------------------------------------------------------
348
+ # Typer command
349
+ # ---------------------------------------------------------------------------
350
+
351
+
352
+ def logs_command(
353
+ log_file: Annotated[
354
+ Path | None,
355
+ typer.Argument(
356
+ help="Path to a local log file. Omit to read from stdin.",
357
+ exists=False,
358
+ show_default=False,
359
+ ),
360
+ ] = None,
361
+ port: Annotated[
362
+ int,
363
+ typer.Option("--port", help="Dashboard port.", show_default=True),
364
+ ] = 7890,
365
+ no_browser: Annotated[
366
+ bool,
367
+ typer.Option("--no-browser", help="Suppress automatic browser open."),
368
+ ] = False,
369
+ ) -> None:
370
+ """Stream logs into a live dashboard."""
371
+ stdin_mode = log_file is None
372
+ if not stdin_mode and not log_file.exists(): # type: ignore[union-attr]
373
+ console.print(f"[bold red]Error:[/bold red] File not found: {log_file}")
374
+ raise typer.Exit(code=1)
375
+
376
+ asyncio.run(_serve(log_file, port=port, open_browser=not no_browser))
377
+
378
+
379
+ async def _serve(
380
+ log_file: Path | None,
381
+ *,
382
+ port: int,
383
+ open_browser: bool,
384
+ ) -> None:
385
+ app = _build_app()
386
+
387
+ config = uvicorn.Config(
388
+ app,
389
+ host="127.0.0.1",
390
+ port=port,
391
+ log_level="error",
392
+ loop="asyncio",
393
+ )
394
+ server = uvicorn.Server(config)
395
+
396
+ if log_file is not None:
397
+ ingest_task = asyncio.create_task(_ingest_file(log_file))
398
+ else:
399
+ ingest_task = asyncio.create_task(_ingest_stdin())
400
+
401
+ url = f"http://127.0.0.1:{port}"
402
+ console.print(
403
+ f"[bold green]devbrief logs[/bold green] dashboard at [link={url}]{url}[/link]"
404
+ )
405
+ console.print("[dim]Press Ctrl+C to stop.[/dim]")
406
+
407
+ if open_browser:
408
+ # Slight delay so the server is ready before the browser hits it
409
+ async def _open() -> None:
410
+ await asyncio.sleep(0.8)
411
+ webbrowser.open(url)
412
+
413
+ asyncio.create_task(_open())
414
+
415
+ try:
416
+ await server.serve()
417
+ except (KeyboardInterrupt, asyncio.CancelledError):
418
+ pass
419
+ finally:
420
+ ingest_task.cancel()
421
+ console.print("\n[dim]Server stopped.[/dim]")