diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 786b7b1..48b9601 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -138,6 +138,25 @@ scrape_configs: - targets: ["localhost:9191"] ``` +## Logs stuck on "Waiting for access logs..." + +**Symptom:** The Logs tab shows "Listening on net 127.0.0.1:XXXXX — waiting for access logs..." indefinitely. + +**Cause:** When running against Caddy in Docker, Ember auto-binds a listener on `127.0.0.1` of the host. Caddy (inside the container) receives this address via the admin API and tries to connect to its own loopback interface, where nothing is listening. + +**Fix:** Tell Ember to bind an address that is reachable from the container. + +- **macOS/Windows:** Use `host.docker.internal`: + ```bash + ember --log-listen host.docker.internal:XXXXX + ``` +- **Linux:** Use your host's LAN IP or the Docker bridge IP (usually `172.17.0.1`): + ```bash + ember --log-listen 172.17.0.1:9210 + ``` + +Make sure the port is not blocked by a firewall on the host. + ## Caddy logs `broken pipe` after Ember exits **Symptom:** After Ember exits uncleanly (`kill -9`, OOM, Caddy busy during quit), Caddy keeps writing `broken pipe` to its stderr every 10 seconds. diff --git a/internal/fetcher/logentry.go b/internal/fetcher/logentry.go index 2f8d348..a29a6d0 100644 --- a/internal/fetcher/logentry.go +++ b/internal/fetcher/logentry.go @@ -41,6 +41,7 @@ type LogEntry struct { RemoteIP string RawLine string ParseError bool + Attributes map[string]any } // caddyLogLine mirrors the JSON shape Caddy emits for access logs. @@ -109,27 +110,41 @@ func ParseLogLine(line string) LogEntry { return LogEntry{ParseError: true, RawLine: line, Timestamp: time.Now().UTC()} } - var parsed caddyLogLine - if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil { + var raw map[string]any + if err := json.Unmarshal([]byte(trimmed), &raw); err != nil { return LogEntry{ParseError: true, RawLine: trimmed, Timestamp: time.Now().UTC()} } + var parsed caddyLogLine + _ = json.Unmarshal([]byte(trimmed), &parsed) + ts := parsed.Timestamp.Time if ts.IsZero() { ts = time.Now().UTC() } + // Remove known fields to keep only extra attributes + delete(raw, "ts") + delete(raw, "level") + delete(raw, "logger") + delete(raw, "msg") + delete(raw, "request") + delete(raw, "status") + delete(raw, "duration") + delete(raw, "size") + return LogEntry{ - Timestamp: ts, - Level: parsed.Level, - Logger: parsed.Logger, - Message: parsed.Message, - Host: parsed.Request.Host, - Method: parsed.Request.Method, - URI: parsed.Request.URI, - Status: parsed.Status, - Duration: parsed.Duration, - Size: parsed.Size, - RemoteIP: parsed.Request.RemoteIP, + Timestamp: ts, + Level: parsed.Level, + Logger: parsed.Logger, + Message: parsed.Message, + Host: parsed.Request.Host, + Method: parsed.Request.Method, + URI: parsed.Request.URI, + Status: parsed.Status, + Duration: parsed.Duration, + Size: parsed.Size, + RemoteIP: parsed.Request.RemoteIP, + Attributes: raw, } } diff --git a/internal/ui/logdetail.go b/internal/ui/logdetail.go new file mode 100644 index 0000000..36e09d1 --- /dev/null +++ b/internal/ui/logdetail.go @@ -0,0 +1,66 @@ +package ui + +import ( + "fmt" + "maps" + "slices" + "strings" + + "github.com/alexandre-daubois/ember/internal/fetcher" + "github.com/charmbracelet/lipgloss" +) + +func renderLogDetailPanel(e fetcher.LogEntry, width, height int) string { + inner := width - 4 + if inner < 10 { + inner = 10 + } + + var lines []string + + crumb := greyStyle.Render("Logs › ") + lines = append(lines, crumb+titleStyle.Render("Details")) + + lines = append(lines, "") + lines = append(lines, sectionHeader("Metadata", inner)) + lines = append(lines, detailKV("Time", e.Timestamp.Format("15:04:05.000"))) + lines = append(lines, detailKV("Level", strings.ToUpper(e.Level))) + if e.Logger != "" { + lines = append(lines, detailKV("Logger", e.Logger)) + } + + lines = append(lines, "") + lines = append(lines, sectionHeader("Message", inner)) + msg := e.Message + if msg == "" { + msg = "—" + } + // Wrap message + wrappedMsg := lipgloss.NewStyle().Width(inner).Render(msg) + lines = append(lines, wrappedMsg) + + if len(e.Attributes) > 0 { + lines = append(lines, "") + lines = append(lines, sectionHeader("Attributes", inner)) + keys := slices.Sorted(maps.Keys(e.Attributes)) + for _, k := range keys { + val := fmt.Sprintf("%v", e.Attributes[k]) + // If value is long, it will be wrapped by detailKV or we might need manual wrapping + lines = append(lines, detailKV(k, val)) + } + } + + lines = append(lines, "") + lines = append(lines, helpStyle.Render(" "+helpKeyStyle.Render("Esc")+" close")) + + content := strings.Join(lines, "\n") + + contentHeight := lipgloss.Height(content) + boxChrome := 2 + available := height - boxChrome + if contentHeight < available { + content += strings.Repeat("\n", available-contentHeight) + } + + return boxStyle.Width(width - 2).Render(content) +} diff --git a/internal/ui/logs.go b/internal/ui/logs.go index d86db7b..959ec41 100644 --- a/internal/ui/logs.go +++ b/internal/ui/logs.go @@ -38,6 +38,7 @@ func (a *App) renderLogsTab(width, height int) string { rightStatus := a.buildLogsHeaderStatus() var table string + var detailPanel string if a.isRoutesView() { table = a.renderRoutesView(tableW, height, rightStatus) } else { @@ -46,6 +47,27 @@ func (a *App) renderLogsTab(width, height int) string { if bodyHeight < 1 { bodyHeight = 1 } + + if a.mode == viewDetail { + if width >= detailSideThreshold { + tableW -= detailPanelWidth + if tableW < 20 { + tableW = 20 + } + } + + // Detail panel content + if a.cursor >= 0 && a.cursor < len(all) { + pWidth := detailPanelWidth + pHeight := height + if width < detailSideThreshold { + pWidth = width + pHeight = detailPanelHeight + 6 // Approximate height for log details + } + detailPanel = renderLogDetailPanel(all[a.cursor], pWidth, pHeight) + } + } + visible, localCursor := a.sliceLogViewport(all, bodyHeight) // When the sidepanel owns keyboard focus, suppress the table row // highlight: seeing both the sidepanel selection and a reversed row @@ -65,7 +87,14 @@ func (a *App) renderLogsTab(width, height int) string { selIdx := sidepanelIndex(items, a.logSel) sidepanel := renderSidepanel(items, selIdx, a.logSidepanelFocused, sidepanelW, height) - return lipgloss.JoinHorizontal(lipgloss.Top, sidepanel, table) + main := lipgloss.JoinHorizontal(lipgloss.Top, sidepanel, table) + if detailPanel != "" { + if width >= detailSideThreshold { + return lipgloss.JoinHorizontal(lipgloss.Top, main, detailPanel) + } + return lipgloss.JoinVertical(lipgloss.Left, main, detailPanel) + } + return main } // renderRoutesView aggregates access logs into per-route stats and slices @@ -543,6 +572,16 @@ func (a *App) handleLogsListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } else { a.freezeLogs() } + case "enter": + if !a.isRoutesView() && a.currentLogsListLen() > 0 { + a.mode = viewDetail + return a, nil + } + case "esc": + if a.mode == viewDetail { + a.mode = viewList + return a, nil + } case "c": if a.isRoutesView() { if a.routeAggregator != nil { @@ -577,9 +616,14 @@ func (a *App) currentLogsListLen() int { func logsHelpBindings(frozen, routesView bool, routeSort string) []binding { bindings := []binding{ {"↑/↓", "navigate"}, - {"←/→", "panel"}, - {"/", "filter"}, } + if !routesView { + bindings = append(bindings, binding{"Enter", "detail"}) + } + bindings = append(bindings, + binding{"←/→", "panel"}, + binding{"/", "filter"}, + ) if routesView { bindings = append(bindings, binding{"s/S", "sort(" + routeSort + ")"}) } else { diff --git a/local/frankenphp/Makefile b/local/frankenphp/Makefile index e334ebd..c811c38 100644 --- a/local/frankenphp/Makefile +++ b/local/frankenphp/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := help -.PHONY: local clean help +.PHONY: local up down traffic up: ## Start docker compose via build & up in the local folder @command -v docker >/dev/null 2>&1 || { echo >&2 "Docker is not installed. Please install it."; exit 1; } @@ -13,5 +13,12 @@ down: ## Clean docker compose in the local folder @command -v docker >/dev/null 2>&1 || { echo >&2 "Docker is not installed. Please install it."; exit 1; } docker compose down --remove-orphans +traffic: ## Generate light traffic against instance + @for i in $$(seq 1 50); do \ + curl -s http://localhost:8080/ > /dev/null & \ + sleep 0.05; \ + done; wait + @echo " done" + help: ## Show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'