Skip to content

Commit 65d6712

Browse files
retlehsclaude
andcommitted
Add real-time log stream viewer to admin dashboard
New /admin/logs page with SSE-based tailing of wpcomposer.log and pipeline.log. Handles missing files gracefully (waits for creation), supports log rotation, and caps browser-side lines at 5000. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 692927b commit 65d6712

File tree

5 files changed

+219
-0
lines changed

5 files changed

+219
-0
lines changed

internal/http/handlers.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net/http"
1010
"os"
1111
"path/filepath"
12+
"time"
1213
"slices"
1314
"strconv"
1415
"strings"
@@ -317,6 +318,129 @@ func handleAdminBuilds(a *app.App, tmpl *templateSet) http.HandlerFunc {
317318
}
318319
}
319320

321+
var logFiles = map[string]string{
322+
"wpcomposer": filepath.Join("storage", "logs", "wpcomposer.log"),
323+
"pipeline": filepath.Join("storage", "logs", "pipeline.log"),
324+
}
325+
326+
func handleAdminLogs(tmpl *templateSet) http.HandlerFunc {
327+
return func(w http.ResponseWriter, r *http.Request) {
328+
render(w, tmpl.adminLogs, "admin_layout", nil)
329+
}
330+
}
331+
332+
func handleAdminLogStream(a *app.App) http.HandlerFunc {
333+
return func(w http.ResponseWriter, r *http.Request) {
334+
file := r.URL.Query().Get("file")
335+
logPath, ok := logFiles[file]
336+
if !ok {
337+
http.Error(w, "unknown log file", http.StatusBadRequest)
338+
return
339+
}
340+
341+
flusher, ok := w.(http.Flusher)
342+
if !ok {
343+
http.Error(w, "streaming not supported", http.StatusInternalServerError)
344+
return
345+
}
346+
347+
w.Header().Set("Content-Type", "text/event-stream")
348+
w.Header().Set("Cache-Control", "no-cache")
349+
w.Header().Set("Connection", "keep-alive")
350+
w.Header().Set("X-Accel-Buffering", "no")
351+
352+
ctx := r.Context()
353+
ticker := time.NewTicker(500 * time.Millisecond)
354+
defer ticker.Stop()
355+
356+
// Wait for the file to exist
357+
var f *os.File
358+
for f == nil {
359+
if opened, err := os.Open(logPath); err == nil {
360+
f = opened
361+
} else {
362+
fmt.Fprintf(w, "data: Waiting for %s ...\n\n", filepath.Base(logPath))
363+
flusher.Flush()
364+
select {
365+
case <-ctx.Done():
366+
return
367+
case <-time.After(2 * time.Second):
368+
}
369+
}
370+
}
371+
defer f.Close()
372+
373+
// Send initial batch: last 200 lines
374+
lines := tailFile(logPath, 200)
375+
for _, line := range lines {
376+
fmt.Fprintf(w, "data: %s\n\n", line)
377+
}
378+
flusher.Flush()
379+
380+
// Seek to end for tailing
381+
offset, _ := f.Seek(0, 2)
382+
383+
buf := make([]byte, 64*1024)
384+
var partial string
385+
for {
386+
select {
387+
case <-ctx.Done():
388+
return
389+
case <-ticker.C:
390+
info, err := os.Stat(logPath)
391+
if err != nil {
392+
continue
393+
}
394+
if info.Size() < offset {
395+
// File was truncated/rotated, reopen
396+
f.Close()
397+
f, err = os.Open(logPath)
398+
if err != nil {
399+
continue
400+
}
401+
offset = 0
402+
}
403+
if info.Size() == offset {
404+
continue
405+
}
406+
_, _ = f.Seek(offset, 0)
407+
n, err := f.Read(buf)
408+
if n > 0 {
409+
offset += int64(n)
410+
chunk := partial + string(buf[:n])
411+
partial = ""
412+
newLines := strings.Split(chunk, "\n")
413+
if !strings.HasSuffix(chunk, "\n") {
414+
partial = newLines[len(newLines)-1]
415+
newLines = newLines[:len(newLines)-1]
416+
}
417+
for _, line := range newLines {
418+
if line != "" {
419+
fmt.Fprintf(w, "data: %s\n\n", line)
420+
}
421+
}
422+
flusher.Flush()
423+
}
424+
if err != nil {
425+
continue
426+
}
427+
}
428+
}
429+
}
430+
}
431+
432+
func tailFile(path string, n int) []string {
433+
data, err := os.ReadFile(path)
434+
if err != nil {
435+
return nil
436+
}
437+
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
438+
if len(lines) > n {
439+
lines = lines[len(lines)-n:]
440+
}
441+
return lines
442+
}
443+
320444
// ogImageURL returns the full OG image URL for a given key.
321445
// In production, it uses the CDN. In local dev, it uses /og/ routes.
322446
func ogImageURL(cfg *config.Config, key string) string {

internal/http/router.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ func NewRouter(a *app.App) chi.Router {
9191
r.Get("/", handleAdminDashboard(a, tmpl))
9292
r.Get("/packages", handleAdminPackages(a, tmpl))
9393
r.Get("/builds", handleAdminBuilds(a, tmpl))
94+
r.Get("/logs", handleAdminLogs(tmpl))
95+
r.Get("/logs/stream", handleAdminLogStream(a))
9496
})
9597

9698
r.Mount("/admin", admin)

internal/http/templates.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type templateSet struct {
3636
adminDashboard *template.Template
3737
adminPackages *template.Template
3838
adminBuilds *template.Template
39+
adminLogs *template.Template
3940
}
4041

4142
func loadTemplates(env string) *templateSet {
@@ -50,6 +51,7 @@ func loadTemplates(env string) *templateSet {
5051
adminDashboard: parse("templates/admin_layout.html", "templates/admin_dashboard.html"),
5152
adminPackages: parse("templates/admin_layout.html", "templates/admin_packages.html"),
5253
adminBuilds: parse("templates/admin_layout.html", "templates/admin_builds.html"),
54+
adminLogs: parse("templates/admin_layout.html", "templates/admin_logs.html"),
5355
}
5456
}
5557

internal/http/templates/admin_layout.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<a href="/admin" class="hover:text-gray-300">Dashboard</a>
2020
<a href="/admin/packages" class="hover:text-gray-300">Packages</a>
2121
<a href="/admin/builds" class="hover:text-gray-300">Builds</a>
22+
<a href="/admin/logs" class="hover:text-gray-300">Logs</a>
2223
<form method="POST" action="/admin/logout" class="inline">
2324
<button type="submit" class="hover:text-gray-300">Logout</button>
2425
</form>
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
{{template "admin_layout" .}}
2+
{{define "title"}}Logs{{end}}
3+
{{define "content"}}
4+
<div class="flex items-center justify-between mb-4">
5+
<h1 class="text-xl font-bold">Logs</h1>
6+
<div class="flex items-center gap-2 text-sm">
7+
<span id="status" class="text-gray-400">Connecting...</span>
8+
<button onclick="toggleScroll()" id="scrollBtn" class="px-3 py-1 rounded border border-gray-300 bg-white hover:bg-gray-50 text-xs">Auto-scroll: ON</button>
9+
<button onclick="clearLog()" class="px-3 py-1 rounded border border-gray-300 bg-white hover:bg-gray-50 text-xs">Clear</button>
10+
</div>
11+
</div>
12+
<div class="flex gap-1 mb-2" role="tablist">
13+
<button onclick="switchTab('wpcomposer')" id="tab-wpcomposer" role="tab" class="px-4 py-2 text-sm font-medium rounded-t border border-b-0 border-gray-300 bg-white">wpcomposer.log</button>
14+
<button onclick="switchTab('pipeline')" id="tab-pipeline" role="tab" class="px-4 py-2 text-sm font-medium rounded-t border border-b-0 border-gray-200 bg-gray-100 text-gray-500">pipeline.log</button>
15+
</div>
16+
<div id="log-container" class="bg-gray-900 text-gray-100 rounded-b rounded-r border border-gray-300 font-mono text-xs leading-relaxed overflow-auto" style="height: calc(100vh - 260px);">
17+
<pre id="log-output" class="p-4 whitespace-pre-wrap break-all"></pre>
18+
</div>
19+
<script>
20+
let currentTab = 'wpcomposer';
21+
let eventSource = null;
22+
let autoScroll = true;
23+
const maxLines = 5000;
24+
25+
function switchTab(tab) {
26+
currentTab = tab;
27+
document.querySelectorAll('[role="tab"]').forEach(function(el) {
28+
if (el.id === 'tab-' + tab) {
29+
el.className = 'px-4 py-2 text-sm font-medium rounded-t border border-b-0 border-gray-300 bg-white';
30+
} else {
31+
el.className = 'px-4 py-2 text-sm font-medium rounded-t border border-b-0 border-gray-200 bg-gray-100 text-gray-500';
32+
}
33+
});
34+
document.getElementById('log-output').textContent = '';
35+
connect();
36+
}
37+
38+
function connect() {
39+
if (eventSource) {
40+
eventSource.close();
41+
}
42+
var status = document.getElementById('status');
43+
status.textContent = 'Connecting...';
44+
status.className = 'text-gray-400';
45+
46+
eventSource = new EventSource('/admin/logs/stream?file=' + currentTab);
47+
eventSource.onopen = function() {
48+
status.textContent = 'Connected';
49+
status.className = 'text-green-600';
50+
};
51+
eventSource.onmessage = function(e) {
52+
var output = document.getElementById('log-output');
53+
var container = document.getElementById('log-container');
54+
output.textContent += e.data + '\n';
55+
56+
// Trim if too many lines
57+
var lines = output.textContent.split('\n');
58+
if (lines.length > maxLines) {
59+
output.textContent = lines.slice(lines.length - maxLines).join('\n');
60+
}
61+
62+
if (autoScroll) {
63+
container.scrollTop = container.scrollHeight;
64+
}
65+
};
66+
eventSource.onerror = function() {
67+
status.textContent = 'Disconnected';
68+
status.className = 'text-red-500';
69+
eventSource.close();
70+
setTimeout(connect, 3000);
71+
};
72+
}
73+
74+
function toggleScroll() {
75+
autoScroll = !autoScroll;
76+
var btn = document.getElementById('scrollBtn');
77+
btn.textContent = 'Auto-scroll: ' + (autoScroll ? 'ON' : 'OFF');
78+
if (autoScroll) {
79+
var container = document.getElementById('log-container');
80+
container.scrollTop = container.scrollHeight;
81+
}
82+
}
83+
84+
function clearLog() {
85+
document.getElementById('log-output').textContent = '';
86+
}
87+
88+
connect();
89+
</script>
90+
{{end}}

0 commit comments

Comments
 (0)