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.
- {devbrief-0.2.0 → devbrief-0.3.0}/CLAUDE.md +1 -1
- {devbrief-0.2.0 → devbrief-0.3.0}/PKG-INFO +46 -3
- {devbrief-0.2.0 → devbrief-0.3.0}/README.md +42 -2
- {devbrief-0.2.0 → devbrief-0.3.0}/pyproject.toml +4 -1
- {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/cli.py +2 -0
- devbrief-0.3.0/src/devbrief/commands/logs.py +421 -0
- devbrief-0.3.0/src/devbrief/templates/base.html +147 -0
- devbrief-0.3.0/src/devbrief/templates/logs/dashboard.html +152 -0
- devbrief-0.3.0/tests/test_logs.py +331 -0
- {devbrief-0.2.0 → devbrief-0.3.0}/uv.lock +124 -1
- {devbrief-0.2.0 → devbrief-0.3.0}/.github/workflows/ci.yml +0 -0
- {devbrief-0.2.0 → devbrief-0.3.0}/.github/workflows/release.yml +0 -0
- {devbrief-0.2.0 → devbrief-0.3.0}/.gitignore +0 -0
- {devbrief-0.2.0 → devbrief-0.3.0}/.python-version +0 -0
- {devbrief-0.2.0 → devbrief-0.3.0}/LICENSE +0 -0
- {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/__init__.py +0 -0
- {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/brief.py +0 -0
- {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/commands/__init__.py +0 -0
- {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/commands/auth.py +0 -0
- {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/commands/repo.py +0 -0
- {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/core/__init__.py +0 -0
- {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/core/config.py +0 -0
- {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/core/credentials.py +0 -0
- {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/display.py +0 -0
- {devbrief-0.2.0 → devbrief-0.3.0}/src/devbrief/github.py +0 -0
- {devbrief-0.2.0 → devbrief-0.3.0}/tests/__init__.py +0 -0
- {devbrief-0.2.0 → devbrief-0.3.0}/tests/test_credentials.py +0 -0
- {devbrief-0.2.0 → devbrief-0.3.0}/tests/test_display.py +0 -0
- {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 |
|
|
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.
|
|
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`
|
|
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
|
-
│
|
|
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`
|
|
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
|
-
│
|
|
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.
|
|
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]")
|