Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 28 additions & 13 deletions internal/fetcher/logentry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
}
}
66 changes: 66 additions & 0 deletions internal/ui/logdetail.go
Original file line number Diff line number Diff line change
@@ -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)
}
50 changes: 47 additions & 3 deletions internal/ui/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -577,9 +616,14 @@ func (a *App) currentLogsListLen() int {
func logsHelpBindings(frozen, routesView bool, routeSort string) []binding {
bindings := []binding{
{"↑/↓", "navigate"},
{"←/→", "panel"},
{"/", "filter"},
}
if !routesView {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

routesViews are aggregated so no unitary metadata

bindings = append(bindings, binding{"Enter", "detail"})
}
bindings = append(bindings,
binding{"←/→", "panel"},
binding{"/", "filter"},
)
if routesView {
bindings = append(bindings, binding{"s/S", "sort(" + routeSort + ")"})
} else {
Expand Down
9 changes: 8 additions & 1 deletion local/frankenphp/Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.DEFAULT_GOAL := help

.PHONY: local clean help
.PHONY: local up down traffic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably a miss rewrite from my original makefile ;)


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; }
Expand All @@ -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}'
Loading