mirror of
https://github.com/wweir/sower.git
synced 2026-10-19 16:14:33 +00:00
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:
+16
-4
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user