Skip to content

Commit 34aafb8

Browse files
committed
feat: add web UI with editorial dashboard, analytics, and chameleon mascot
Adds `llm-log ui` command serving a browser-based dashboard at localhost:8080. Web UI: - Dashboard with real-time animated metrics, area charts, provider bars - Requests page with pagination, filters, sorting, click-to-copy values - Analytics with tabbed sections (Cost/Tokens/Performance), summary strips - Pixel-art chameleon mascot — roams UI, changes color per provider, interactive Backend: - Fix zero-time bug in DashboardStats/Analytics - Fix unbounded dashboard cache, NULL cursor pagination - Refactor Analytics into sub-methods, extract timeFilter helper Quality: - Code splitting (React.lazy), WCAG contrast, ARIA labels - Mobile responsive, prefers-reduced-motion, search debounce - Chart colors tokenized, typography normalized, animations polished
1 parent 38fe635 commit 34aafb8

75 files changed

Lines changed: 14430 additions & 170 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/build.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ jobs:
1717
- uses: actions/setup-go@v6
1818
with:
1919
go-version-file: go.mod
20+
- uses: actions/setup-node@v4
21+
with:
22+
node-version: 22
23+
cache: npm
24+
cache-dependency-path: web/package-lock.json
25+
- name: Build web UI
26+
run: cd web && npm ci && npm run build
2027
- run: go vet ./...
2128
- run: go test ./...
2229

@@ -31,6 +38,11 @@ jobs:
3138
- uses: actions/setup-go@v6
3239
with:
3340
go-version-file: go.mod
41+
- uses: actions/setup-node@v4
42+
with:
43+
node-version: 22
44+
cache: npm
45+
cache-dependency-path: web/package-lock.json
3446
- uses: goreleaser/goreleaser-action@v6
3547
with:
3648
version: latest

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,8 @@ completions/
1212
.DS_Store
1313

1414
.claude/settings.local.json
15+
16+
# Web UI
17+
web/node_modules/
18+
web/dist/
19+
!web/dist/index.html

.goreleaser.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ version: 2
22

33
before:
44
hooks:
5+
- go mod tidy
6+
- cd web && npm ci && npm run build
57
- go build -o /tmp/llm-log-completions ./cmd/llm-log
68
- mkdir -p completions
79
- sh -c "/tmp/llm-log-completions completion bash > completions/llm-log.bash"

CHANGELOG.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
# Changelog
22

3-
## [0.4.0] — 2026-03-21
3+
## [0.4.0] — 2026-03-22
44

55
### Added
6+
- **Web UI**`llm-log ui` opens a browser-based dashboard at `localhost:9923`
7+
- **Dashboard** — real-time metrics with animated counters, area charts, provider breakdown bars, top models table
8+
- **Requests** — paginated table with sorting, filtering, search (debounced), page size selector, click-to-open detail dialog
9+
- **Request Detail** — full-page view with copyable values (model, provider, endpoint, ID, metrics), two-column JSON viewer
10+
- **Analytics** — tabbed sections (Cost / Tokens / Performance) with summary strips, 10 chart types including heatmap
11+
- **Chameleon mascot** — pixel-art pet that roams the UI, changes color to match the dominant provider, sleeps when proxy is off, reacts to clicks (pet / startle), has a terminal "home"
12+
- **Click-to-copy** — model names, providers, endpoints, request IDs, metric values are all copyable with visual feedback
613
- **7 new providers** — Groq, Together AI, Fireworks, DeepSeek, Mistral, Perplexity, xAI
714
- DeepSeek: custom cache token support (`prompt_cache_hit_tokens`)
815
- Perplexity: Sonar API format (`/v1/sonar` endpoint)
@@ -11,9 +18,16 @@
1118
- **Wire format parsers moved to `provider/wire` subpackage** — clear separation between providers (domain → format mapping) and wire formats (response parsing)
1219
- Deduplicated Chat Completions parsing via `usageMapper` callback — adding new OpenAI-compatible providers requires only a usage mapper function
1320
- Removed unused `statusCode` parameter from `Format.Parse` interface
21+
- **Analytics refactored** — split into 7 sub-methods with proper row scoping
22+
- **Dashboard cache** — replaced unbounded map with single-entry cache
23+
- **`timeFilter` helper** — extracted shared WHERE clause builder to `helpers.go`
24+
- Code splitting — Dashboard and Analytics lazy-loaded (recharts deferred)
1425

1526
### Fixed
1627
- **Error responses (4xx/5xx) are now tracked** — model name is recovered from the request body when the API response doesn't include it
28+
- **Zero-time bug**`DashboardStats` and `Analytics` now handle all-time queries correctly (IsZero check)
29+
- **NULL cursor pagination** — COALESCE for nullable sort columns prevents skipped records
30+
- **Proxy port** — unified constant, no more magic numbers
1731

1832
## [0.3.0] — 2026-03-19
1933

Makefile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
22
LDFLAGS = -s -w -X github.com/lanesket/llm.log/internal/cli.Version=$(VERSION)
33

4-
.PHONY: build test lint clean setup-hooks
4+
.PHONY: build test lint clean setup-hooks build-ui dev-ui
55

66
build:
77
go build -ldflags "$(LDFLAGS)" -o llm-log ./cmd/llm-log
@@ -17,3 +17,10 @@ clean:
1717

1818
setup-hooks:
1919
git config core.hooksPath .githooks
20+
21+
build-ui:
22+
cd web && npm ci && npm run build
23+
go build -ldflags "$(LDFLAGS)" -o llm-log ./cmd/llm-log
24+
25+
dev-ui:
26+
go run -ldflags "$(LDFLAGS)" ./cmd/llm-log ui --dev

README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ A local proxy that sits between your apps and LLM APIs. It intercepts requests,
3030
- **All API formats** — Chat Completions, Responses API, Anthropic Messages
3131
- **Real costs** — auto-updated pricing for 780+ models, cache token breakdowns
3232
- **Claude Code aware** — on a subscription? see what you'd pay without it. On API keys? see your actual spend
33-
- **TUI dashboard** — overview, charts, cost breakdown, request inspector
33+
- **Web UI** — browser-based dashboard with real-time charts, analytics, and detailed request inspection
34+
- **TUI dashboard** — terminal-based overview, charts, cost breakdown, request inspector
3435
- **Minimal overhead** — logging is async and never blocks your requests
3536
- **Single binary** — pure Go, no CGO, no dependencies
3637

@@ -66,7 +67,19 @@ After setup, **open a new terminal** (or run `source ~/.zshrc`) — then every L
6667
> Tools that route through their own servers (Cursor Pro, VS Code Copilot with built-in subscription) won't be logged.
6768
> If the tool supports your own API key, requests go directly to the provider and llm.log captures them.
6869
69-
## Dashboard
70+
## Web UI
71+
72+
```bash
73+
llm-log ui # opens http://localhost:9923
74+
```
75+
76+
| Page | What it shows |
77+
|------|---------------|
78+
| **Dashboard** | Real-time metrics (animated), area charts (requests/cost/tokens), provider breakdown, top models |
79+
| **Requests** | Paginated table with sorting, filters, search. Click a row to see full detail with copyable values |
80+
| **Analytics** | Tabbed sections — Cost (over time, cumulative, by provider, distribution, top expensive), Tokens (over time, avg/model, cache hit rate), Performance (latency, heatmap) |
81+
82+
## TUI Dashboard
7083

7184
```bash
7285
llm-log dashboard # or: llm-log dash

internal/cli/daemon.go

Lines changed: 3 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,10 @@ package cli
33
import (
44
"fmt"
55
"log"
6-
"net"
76
"os"
8-
"os/exec"
97
"os/signal"
108
"path/filepath"
119
"syscall"
12-
"time"
1310

1411
"github.com/lanesket/llm.log/internal/daemon"
1512
"github.com/lanesket/llm.log/internal/format"
@@ -42,23 +39,9 @@ var startCmd = &cobra.Command{
4239
clearEnvFile()
4340
deactivateSystemProxy()
4441

45-
exe, err := os.Executable()
42+
pid, err := daemon.StartDaemon(proxyAddr, daemonSysProcAttr())
4643
if err != nil {
47-
return fmt.Errorf("find executable: %w", err)
48-
}
49-
proc := exec.Command(exe, "run")
50-
proc.Stdout = nil
51-
proc.Stderr = nil
52-
proc.SysProcAttr = daemonSysProcAttr()
53-
54-
if err := proc.Start(); err != nil {
55-
return fmt.Errorf("start daemon: %w", err)
56-
}
57-
58-
// Wait for the proxy to actually start listening before activating env
59-
if !waitForPort(proxyAddr, 5*time.Second) {
60-
_ = proc.Process.Kill()
61-
return fmt.Errorf("daemon failed to start — check ~/.llm.log/llm-log.log")
44+
return err
6245
}
6346

6447
// Refresh CA bundle (system CAs may have changed since setup)
@@ -69,7 +52,7 @@ var startCmd = &cobra.Command{
6952
}
7053
activateSystemProxy()
7154

72-
fmt.Printf("Started llm-log daemon (PID %d) on %s\n", proc.Process.Pid, proxyAddr)
55+
fmt.Printf("Started llm-log daemon (PID %d) on %s\n", pid, proxyAddr)
7356
fmt.Println("HTTPS_PROXY is now active for new terminals and apps")
7457
return nil
7558
},
@@ -80,19 +63,6 @@ func hasStaleProxyState() bool {
8063
return err == nil
8164
}
8265

83-
func waitForPort(addr string, timeout time.Duration) bool {
84-
deadline := time.Now().Add(timeout)
85-
for time.Now().Before(deadline) {
86-
conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
87-
if err == nil {
88-
conn.Close()
89-
return true
90-
}
91-
time.Sleep(50 * time.Millisecond)
92-
}
93-
return false
94-
}
95-
9666
var stopCmd = &cobra.Command{
9767
Use: "stop",
9868
Short: "Stop the proxy daemon",

internal/cli/ui.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"io/fs"
6+
"net/http"
7+
"os/exec"
8+
"runtime"
9+
10+
"github.com/lanesket/llm.log/internal/storage"
11+
"github.com/lanesket/llm.log/internal/ui"
12+
"github.com/lanesket/llm.log/web"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
var uiCmd = &cobra.Command{
17+
Use: "ui",
18+
Short: "Open web dashboard",
19+
RunE: func(cmd *cobra.Command, args []string) error {
20+
port, _ := cmd.Flags().GetInt("port")
21+
devMode, _ := cmd.Flags().GetBool("dev")
22+
23+
dataDir := DataDir()
24+
25+
store, err := storage.Open(dataDir)
26+
if err != nil {
27+
return fmt.Errorf("open database: %w", err)
28+
}
29+
defer store.Close()
30+
31+
var webFS fs.FS
32+
if !devMode {
33+
sub, err := fs.Sub(web.DistFS, "dist")
34+
if err != nil {
35+
return fmt.Errorf("embed fs: %w", err)
36+
}
37+
webFS = sub
38+
}
39+
40+
srv := ui.New(store, dataDir, webFS, devMode)
41+
42+
addr := fmt.Sprintf("127.0.0.1:%d", port)
43+
url := fmt.Sprintf("http://%s", addr)
44+
fmt.Printf("llm.log UI running at %s\n", url)
45+
46+
if !devMode {
47+
openBrowser(url)
48+
}
49+
50+
return http.ListenAndServe(addr, srv)
51+
},
52+
}
53+
54+
func init() {
55+
uiCmd.Flags().Int("port", 9923, "port for the web UI server")
56+
uiCmd.Flags().Bool("dev", false, "development mode (API only, no static files)")
57+
rootCmd.AddCommand(uiCmd)
58+
}
59+
60+
func openBrowser(url string) {
61+
switch runtime.GOOS {
62+
case "darwin":
63+
exec.Command("open", url).Start()
64+
case "linux":
65+
exec.Command("xdg-open", url).Start()
66+
}
67+
}

internal/daemon/start.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package daemon
2+
3+
import (
4+
"fmt"
5+
"net"
6+
"os"
7+
"os/exec"
8+
"syscall"
9+
"time"
10+
)
11+
12+
// StartDaemon forks a new process running "llm-log run" and waits for it to
13+
// start listening on the given address. Returns the PID of the new process.
14+
func StartDaemon(addr string, procAttr *syscall.SysProcAttr) (int, error) {
15+
exe, err := os.Executable()
16+
if err != nil {
17+
return 0, fmt.Errorf("find executable: %w", err)
18+
}
19+
proc := exec.Command(exe, "run")
20+
proc.Stdout = nil
21+
proc.Stderr = nil
22+
proc.SysProcAttr = procAttr
23+
24+
if err := proc.Start(); err != nil {
25+
return 0, fmt.Errorf("start daemon: %w", err)
26+
}
27+
28+
// Wait for the proxy to actually start listening
29+
if !waitForPort(addr, 5*time.Second) {
30+
_ = proc.Process.Kill()
31+
return 0, fmt.Errorf("daemon failed to start — check ~/.llm.log/llm-log.log")
32+
}
33+
34+
return proc.Process.Pid, nil
35+
}
36+
37+
func waitForPort(addr string, timeout time.Duration) bool {
38+
deadline := time.Now().Add(timeout)
39+
for time.Now().Before(deadline) {
40+
conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
41+
if err == nil {
42+
conn.Close()
43+
return true
44+
}
45+
time.Sleep(50 * time.Millisecond)
46+
}
47+
return false
48+
}

internal/proxy/batcher_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,25 @@ func (m *mockStore) Recent(_ int, _, _ time.Time, _, _ string) ([]storage.Record
4141
}
4242
func (m *mockStore) Get(_ int64) (*storage.Record, error) { return nil, nil }
4343
func (m *mockStore) Sources(_, _ time.Time) ([]string, error) { return nil, nil }
44+
func (m *mockStore) List(_ storage.ListFilter) (*storage.ListResult, error) {
45+
return &storage.ListResult{}, nil
46+
}
47+
func (m *mockStore) DashboardStats(_, _ time.Time) (*storage.DashboardData, error) {
48+
return &storage.DashboardData{}, nil
49+
}
4450
func (m *mockStore) PrunePreview(_ time.Time) (storage.PruneStats, error) {
4551
return storage.PruneStats{}, nil
4652
}
53+
func (m *mockStore) Analytics(_, _ time.Time, _ string) (*storage.AnalyticsData, error) {
54+
return &storage.AnalyticsData{}, nil
55+
}
56+
func (m *mockStore) SearchByBody(_ string, _, _ time.Time, _, _ int) ([]int64, error) {
57+
return nil, nil
58+
}
59+
func (m *mockStore) Filters(_, _ time.Time) (*storage.FilterOptions, error) {
60+
return &storage.FilterOptions{}, nil
61+
}
62+
func (m *mockStore) MaxID() (int64, error) { return 0, nil }
4763
func (m *mockStore) PruneBodies(_ time.Time) (int64, error) { return 0, nil }
4864
func (m *mockStore) Vacuum() error { return nil }
4965
func (m *mockStore) Close() error { return nil }

0 commit comments

Comments
 (0)