fix(admin): bound error alert to recent window, keep UTF-8-safe details

Review fixes for the error visibility feature: the destructive alert now
only shows while a failure occurred within the last 10 minutes, so a past
outage does not keep a red banner up forever (cumulative counts remain as
context). Error details are truncated on a UTF-8 rune boundary and have
line breaks collapsed, so an IDN host cannot produce invalid UTF-8 in the
SSE payload and a multiline error cannot break the alert layout. Adds
tests for both.
This commit is contained in:
wweir
2026-08-11 10:29:10 +08:00
parent cfddf76eb3
commit c86937820a
3 changed files with 58 additions and 7 deletions
+16 -4
View File
@@ -8,6 +8,7 @@ import (
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/sower-proxy/sower/internal/metrics"
)
@@ -363,10 +364,7 @@ func (s *Stats) RecordProxyError(kind string, detail string) {
}
s.metrics.RecordProxyError(kind)
if len(detail) > maxErrorDetailLen {
detail = detail[:maxErrorDetailLen]
}
ev := ErrorEvent{At: time.Now(), Kind: kind, Detail: detail}
ev := ErrorEvent{At: time.Now(), Kind: kind, Detail: cleanErrorDetail(detail)}
s.errMu.Lock()
s.errEvents = append(s.errEvents, ev)
if len(s.errEvents) > maxErrorEvents {
@@ -375,6 +373,20 @@ func (s *Stats) RecordProxyError(kind string, detail string) {
s.errMu.Unlock()
}
// cleanErrorDetail bounds an error detail to the byte budget without
// splitting a UTF-8 rune (a host can be an IDN in Chinese), and collapses
// line breaks so a multiline error cannot break the console alert layout.
func cleanErrorDetail(detail string) string {
detail = strings.ReplaceAll(detail, "\n", " ")
if len(detail) > maxErrorDetailLen {
detail = detail[:maxErrorDetailLen]
for !utf8.ValidString(detail) {
detail = detail[:len(detail)-1]
}
}
return detail
}
// RecordRoute counts one connection routing decision. It is called exactly
// once per connection by the router observer. Block decisions additionally
// attribute the domain to the per-domain block counter; the domain may be
+31
View File
@@ -10,6 +10,7 @@ import (
"sync"
"testing"
"time"
"unicode/utf8"
)
type fakeConn struct {
@@ -510,6 +511,36 @@ func TestStatsErrorEventDetailTruncated(t *testing.T) {
}
}
func TestStatsErrorEventDetailTruncationKeepsValidUTF8(t *testing.T) {
s := newTestStats(t)
// A Chinese host name is 3 bytes per rune; a byte-sliced truncation would
// split a rune and emit invalid UTF-8 into the JSON payload.
s.RecordProxyError("dial", strings.Repeat("中", 50))
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Events) != 1 {
t.Fatalf("expected 1 event, got %d", len(snap.Events))
}
d := snap.Events[0].Detail
if !utf8.ValidString(d) {
t.Fatalf("truncated detail must stay valid UTF-8: %q", d)
}
if len(d) > maxErrorDetailLen {
t.Fatalf("detail exceeds byte budget: %d > %d", len(d), maxErrorDetailLen)
}
}
func TestStatsErrorEventDetailCollapsesNewlines(t *testing.T) {
s := newTestStats(t)
s.RecordProxyError("dial", "first line\nsecond line\n")
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Events) != 1 {
t.Fatalf("expected 1 event, got %d", len(snap.Events))
}
if d := snap.Events[0].Detail; strings.ContainsAny(d, "\r\n") {
t.Fatalf("detail must not contain line breaks: %q", d)
}
}
func TestStatsBlockedCapIsEnforced(t *testing.T) {
s := newTestStats(t)
now := time.Now()
+11 -3
View File
@@ -29,10 +29,18 @@
const history = $derived(live.history)
// Proxy-side failures (upstream dial, DNS, accept) surface as a service
// alert; blocked connections are routing decisions and never counted.
// alert; blocked connections are routing decisions and never counted. The
// alert reflects the current state: it only shows while a failure happened
// within alertWindowMs, so a past outage does not keep a red banner up
// forever. The cumulative counts are shown as context inside it.
const alertWindowMs = 10 * 60 * 1000
const errorTotal = $derived(
traffic ? traffic.errors.dial + traffic.errors.dns + traffic.errors.accept : 0,
)
const errorActive = $derived.by(() => {
const first = traffic?.events[0]
return !!first && Date.now() - new Date(first.at).getTime() < alertWindowMs
})
const recentErrors = $derived(traffic?.events.slice(0, 3) ?? [])
const errorKindLabel = (kind: string) =>
kind === 'dial' ? '连接上游失败' : kind === 'dns' ? 'DNS 解析失败' : '连接接收失败'
@@ -70,7 +78,7 @@
<Alert.AlertDescription>连接正常,但数据已停止更新,请刷新页面</Alert.AlertDescription>
</Alert.Alert>
{/if}
{#if errorTotal > 0}
{#if errorActive}
<Alert.Alert variant="destructive" class="mb-4">
<CircleAlert class="size-4" />
<Alert.AlertDescription>
@@ -83,7 +91,7 @@
</p>
{#if recentErrors.length > 0}
<ul class="mt-2 space-y-1 text-xs">
{#each recentErrors as ev (ev.at + ev.detail)}
{#each recentErrors as ev, i}
<li class="tabular-nums">
<span class="text-muted-foreground">{formatTime(ev.at)}</span>
<span class="text-destructive">{errorKindLabel(ev.kind)}</span>