Compare commits

...
1 Commits
Author SHA1 Message Date
MengMengCode 688e1e8311 feat(logging): implement log retention policy with hard limit and exclusion filters
- Added MaxLogEvents constant to enforce a hard limit on stored log events.
- Updated AppendLogEvent to discard older logs when the limit is exceeded.
- Enhanced ListLogEvents to support filtering by log level and excluding specific messages.
- Introduced ClearLogEvents method to permanently remove logs and prevent re-queuing of cleared entries.
- Modified LogRetentionCard component to reflect the new log retention settings and limits.
- Added logging categories for better organization and filtering in the UI.
- Implemented sanitization for sensitive information in logs.
- Added tests for log event limits and clearing functionality.
2026-08-21 01:01:41 +08:00
27 changed files with 982 additions and 85 deletions
+13 -7
View File
@@ -41,7 +41,7 @@ import (
) )
func main() { func main() {
logs := loghub.New(slog.NewJSONHandler(os.Stdout, nil), 2000) logs := loghub.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), 2000)
logger := slog.New(logs) logger := slog.New(logs)
args := os.Args[1:] args := os.Args[1:]
@@ -206,7 +206,8 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
} }
cardReaders := pcsc.New() cardReaders := pcsc.New()
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders, Logger: logger}) deviceLogger := logger.With("category", "hardware")
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders, Logger: deviceLogger})
if err != nil { if err != nil {
return fmt.Errorf("create device manager: %w", err) return fmt.Errorf("create device manager: %w", err)
} }
@@ -227,7 +228,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
}() }()
pollContext, cancelPolling := context.WithCancel(context.Background()) pollContext, cancelPolling := context.WithCancel(context.Background())
defer cancelPolling() defer cancelPolling()
go pollDeviceSnapshots(pollContext, logger, database, deviceManager) go pollDeviceSnapshots(pollContext, deviceLogger, database, deviceManager)
go restoreConfiguredCellularData(pollContext, logger, database, deviceManager) go restoreConfiguredCellularData(pollContext, logger, database, deviceManager)
go collectCellularTraffic(pollContext, logger, database) go collectCellularTraffic(pollContext, logger, database)
go persistLogsToStore(pollContext, logger, logs, database) go persistLogsToStore(pollContext, logger, logs, database)
@@ -640,7 +641,7 @@ func configureVoWiFiRuntime(
Devices: mapper, Devices: mapper,
} }
manager := vowifiruntime.New(vowifiruntime.Options{ manager := vowifiruntime.New(vowifiruntime.Options{
Logger: logger, Logger: logger.With("category", "vowifi"),
OnState: projector.Save, OnState: projector.Save,
Factory: func(factoryContext context.Context, deviceID string) (*vowifi.Orchestrator, error) { Factory: func(factoryContext context.Context, deviceID string) (*vowifi.Orchestrator, error) {
deviceConfig, err := database.Device(factoryContext, deviceID) deviceConfig, err := database.Device(factoryContext, deviceID)
@@ -717,7 +718,7 @@ func protectVoWiFiStartupRadioWithRetry(
physicalID string, physicalID string,
attempts int, attempts int,
delay time.Duration, delay time.Duration,
) error { ) error {
var lastErr error var lastErr error
for attempt := 0; attempt < attempts; attempt++ { for attempt := 0; attempt < attempts; attempt++ {
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second) flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
@@ -759,14 +760,15 @@ func newVoWiFiOrchestrator(
if apn == "" { if apn == "" {
apn = "ims" apn = "ims"
} }
vowifiLogger := logger.With("category", "vowifi", "device_id", deviceConfig.ID)
tunnelProvider, err := ike.NewProvider(ike.Config{ tunnelProvider, err := ike.NewProvider(ike.Config{
APN: apn, Logger: logger, AutoProposalFallback: true, APN: apn, Logger: vowifiLogger, AutoProposalFallback: true,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err) return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
} }
imsProvider, err := ims.NewProvider(adapter, ims.Config{ imsProvider, err := ims.NewProvider(adapter, ims.Config{
Logger: logger, Logger: vowifiLogger,
// Carrier-specific transport and SMSC defaults live in the shared data // Carrier-specific transport and SMSC defaults live in the shared data
// profile. Prefer network-provided P-CSCF hints, then safely try the // profile. Prefer network-provided P-CSCF hints, then safely try the
// alternate transport only if no SIP response was observed. // alternate transport only if no SIP response was observed.
@@ -983,6 +985,10 @@ func persistLogsToStore(
if !ok { if !ok {
return return
} }
if loghub.IsHTTPAccessEntry(entry) {
continue
}
entry = loghub.SanitizeEntry(entry)
var fields json.RawMessage var fields json.RawMessage
if len(entry.Fields) > 0 { if len(entry.Fields) > 0 {
if raw, err := json.Marshal(entry.Fields); err == nil { if raw, err := json.Marshal(entry.Fields); err == nil {
+54
View File
@@ -206,6 +206,11 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
} }
seen := make(map[string]struct{}, len(candidates)) seen := make(map[string]struct{}, len(candidates))
type discoveryEvent struct {
connected bool
candidate modem.Candidate
}
events := make([]discoveryEvent, 0)
manager.mu.Lock() manager.mu.Lock()
for _, candidate := range candidates { for _, candidate := range candidates {
if strings.TrimSpace(candidate.ID) == "" { if strings.TrimSpace(candidate.ID) == "" {
@@ -218,8 +223,12 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
candidate: candidate, candidate: candidate,
discovered: true, discovered: true,
} }
events = append(events, discoveryEvent{connected: true, candidate: candidate})
continue continue
} }
if !state.discovered {
events = append(events, discoveryEvent{connected: true, candidate: candidate})
}
if state.candidate.ATPort.OpenPath() != candidate.ATPort.OpenPath() { if state.candidate.ATPort.OpenPath() != candidate.ATPort.OpenPath() {
state.resetClientOnLock = true state.resetClientOnLock = true
} }
@@ -231,10 +240,28 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
if _, ok := seen[id]; ok { if _, ok := seen[id]; ok {
continue continue
} }
if state.discovered {
events = append(events, discoveryEvent{candidate: state.candidate})
}
state.discovered = false state.discovered = false
stale = append(stale, state) stale = append(stale, state)
} }
manager.mu.Unlock() manager.mu.Unlock()
if manager.logger != nil {
for _, event := range events {
message := "hardware disconnected"
if event.connected {
message = "hardware connected"
}
manager.logger.Info(message,
"event", "hardware.discovery",
"device_id", event.candidate.ID,
"hardware_kind", event.candidate.HardwareKind,
"vendor_id", event.candidate.VendorID,
"product_id", event.candidate.ProductID,
)
}
}
for _, state := range stale { for _, state := range stale {
state.opMu.Lock() state.opMu.Lock()
@@ -382,6 +409,11 @@ func (manager *Manager) setResult(
return return
} }
previousError := state.lastError previousError := state.lastError
var previousSnapshot *Snapshot
if state.snapshot != nil {
value := *state.snapshot
previousSnapshot = &value
}
if snapshot != nil { if snapshot != nil {
value := *snapshot value := *snapshot
value.Warnings = append([]string(nil), snapshot.Warnings...) value.Warnings = append([]string(nil), snapshot.Warnings...)
@@ -394,6 +426,13 @@ func (manager *Manager) setResult(
state.lastError = "" state.lastError = ""
} }
shouldLog := err != nil && manager.logger != nil && previousError != err.Error() shouldLog := err != nil && manager.logger != nil && previousError != err.Error()
registrationChanged := snapshot != nil && manager.logger != nil &&
(previousSnapshot == nil ||
previousSnapshot.RegistrationStatus != snapshot.RegistrationStatus ||
previousSnapshot.OperatorCode != snapshot.OperatorCode ||
previousSnapshot.AccessTech != snapshot.AccessTech ||
previousSnapshot.PSAttached != snapshot.PSAttached ||
previousSnapshot.SIMStatus != snapshot.SIMStatus)
backend := state.backend backend := state.backend
hardwareKind := state.candidate.HardwareKind hardwareKind := state.candidate.HardwareKind
manager.mu.Unlock() manager.mu.Unlock()
@@ -406,6 +445,21 @@ func (manager *Manager) setResult(
"error", HardwareErrorDetail(err), "error", HardwareErrorDetail(err),
) )
} }
if registrationChanged {
manager.logger.Info(
"cellular registration state changed",
"category", "network",
"event", "network.registration",
"device_id", id,
"sim_status", snapshot.SIMStatus,
"registration_status", snapshot.RegistrationStatus,
"registration_source", snapshot.RegistrationSource,
"operator", snapshot.OperatorName,
"operator_code", snapshot.OperatorCode,
"access_technology", snapshot.AccessTech,
"packet_service_attached", snapshot.PSAttached,
)
}
} }
func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate { func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate {
+23 -3
View File
@@ -9,9 +9,9 @@ import (
"time" "time"
) )
// Entry is the stable, secret-neutral representation exposed by the log API. // Entry is the stable, centrally-redacted representation exposed by the log
// Callers remain responsible for never adding credentials or keying material // API. The Hub sanitizes both the downstream handler and the captured entry so
// to slog attributes. // diagnostic logs can be safely exported by users.
type Entry struct { type Entry struct {
Time time.Time `json:"time"` Time time.Time `json:"time"`
Level string `json:"level"` Level string `json:"level"`
@@ -58,6 +58,7 @@ func (h *Hub) Enabled(ctx context.Context, level slog.Level) bool {
} }
func (h *Hub) Handle(ctx context.Context, record slog.Record) error { func (h *Hub) Handle(ctx context.Context, record slog.Record) error {
record = sanitizeRecord(record)
err := h.next.Handle(ctx, record) err := h.next.Handle(ctx, record)
fields := make(map[string]any) fields := make(map[string]any)
for _, attr := range h.attrs { for _, attr := range h.attrs {
@@ -81,6 +82,7 @@ func (h *Hub) Handle(ctx context.Context, record slog.Record) error {
} }
func (h *Hub) WithAttrs(attrs []slog.Attr) slog.Handler { func (h *Hub) WithAttrs(attrs []slog.Attr) slog.Handler {
attrs = sanitizeAttrs(attrs)
nextAttrs := append(append([]slog.Attr(nil), h.attrs...), attrs...) nextAttrs := append(append([]slog.Attr(nil), h.attrs...), attrs...)
return &Hub{ return &Hub{
next: h.next.WithAttrs(attrs), next: h.next.WithAttrs(attrs),
@@ -180,6 +182,24 @@ func (h *Hub) Subscribe(buffer int) (<-chan Entry, func()) {
return channel, cancel return channel, cancel
} }
// Clear drops captured history and every entry currently queued for live and
// persistence subscribers. Subscribers stay connected for future events.
func (h *Hub) Clear() {
h.core.mu.Lock()
h.core.entries = h.core.entries[:0]
for _, subscriber := range h.core.subscribers {
for {
select {
case <-subscriber:
continue
default:
}
break
}
}
h.core.mu.Unlock()
}
func appendAttribute(fields map[string]any, groups []string, attr slog.Attr) { func appendAttribute(fields map[string]any, groups []string, attr slog.Attr) {
attr.Value = attr.Value.Resolve() attr.Value = attr.Value.Resolve()
if attr.Equal(slog.Attr{}) { if attr.Equal(slog.Attr{}) {
+74
View File
@@ -1,9 +1,12 @@
package loghub package loghub
import ( import (
"bytes"
"context" "context"
"errors"
"io" "io"
"log/slog" "log/slog"
"strings"
"testing" "testing"
"time" "time"
) )
@@ -29,6 +32,51 @@ func TestHubHistoryFiltersAndBounds(t *testing.T) {
} }
} }
func TestHubRedactsDownstreamAndHistoryAndPreservesErrors(t *testing.T) {
var output bytes.Buffer
hub := New(slog.NewJSONHandler(&output, nil), 100)
logger := slog.New(hub).With("imsi", "234159611634973")
logger.Warn(
"delivery to +447700900123 failed",
"iccid", "8944101234567890123",
"peer", "+447700900456",
"error", errors.New("modem rejected MSISDN=447700900789 with +CMS ERROR: 305"),
)
entry := hub.History(1, slog.LevelDebug, "")[0]
if strings.Contains(entry.Message, "447700900123") {
t.Fatalf("message was not redacted: %q", entry.Message)
}
for _, key := range []string{"imsi", "iccid", "peer"} {
if value := entry.Fields[key]; !strings.Contains(value.(string), "REDACTED") {
t.Fatalf("%s = %#v, want redacted", key, value)
}
}
errorText, ok := entry.Fields["error"].(string)
if !ok || !strings.Contains(errorText, "+CMS ERROR: 305") || strings.Contains(errorText, "447700900789") {
t.Fatalf("error = %#v, want original modem error with identity redacted", entry.Fields["error"])
}
if raw := output.String(); strings.Contains(raw, "234159611634973") || strings.Contains(raw, "447700900") {
t.Fatalf("downstream output leaked an identity: %s", raw)
}
}
func TestSanitizeEntryProtectsLegacyNestedFields(t *testing.T) {
entry := SanitizeEntry(Entry{
Message: "incoming SIP from sip:[email protected]",
Fields: map[string]any{
"details": map[string]any{"associated_number": "+447700900456", "status": "registered"},
},
})
if strings.Contains(entry.Message, "447700900123") {
t.Fatalf("message = %q", entry.Message)
}
details := entry.Fields["details"].(map[string]any)
if strings.Contains(details["associated_number"].(string), "447700900456") {
t.Fatalf("nested field leaked: %#v", details)
}
}
func TestHubSubscription(t *testing.T) { func TestHubSubscription(t *testing.T) {
hub := New(slog.NewTextHandler(io.Discard, nil), 100) hub := New(slog.NewTextHandler(io.Discard, nil), 100)
entries, cancel := hub.Subscribe(1) entries, cancel := hub.Subscribe(1)
@@ -47,3 +95,29 @@ func TestHubSubscription(t *testing.T) {
t.Fatal("timed out waiting for log entry") t.Fatal("timed out waiting for log entry")
} }
} }
func TestHubClearDropsHistoryAndQueuedEntries(t *testing.T) {
hub := New(slog.NewTextHandler(io.Discard, nil), 100)
entries, cancel := hub.Subscribe(4)
defer cancel()
logger := slog.New(hub)
logger.Info("before clear")
hub.Clear()
if history := hub.History(10, slog.LevelDebug, ""); len(history) != 0 {
t.Fatalf("history after Clear = %#v", history)
}
select {
case entry := <-entries:
t.Fatalf("queued entry survived Clear: %#v", entry)
default:
}
logger.Info("after clear")
select {
case entry := <-entries:
if entry.Message != "after clear" {
t.Fatalf("entry = %#v", entry)
}
case <-time.After(time.Second):
t.Fatal("subscriber did not remain active after Clear")
}
}
+215
View File
@@ -0,0 +1,215 @@
package loghub
import (
"encoding/json"
"fmt"
"log/slog"
"reflect"
"regexp"
"strings"
"time"
"unicode"
)
var (
sipIdentityPattern = regexp.MustCompile(`(?i)\b(sips?|tel):([^@;>,\s]+)(@[^;>,\s]+)?`)
internationalPhonePattern = regexp.MustCompile(`(?:\+|00)[0-9][0-9 ()-]{5,}[0-9]`)
longDigitsPattern = regexp.MustCompile(`\b[0-9]{7,22}\b`)
labeledIdentityPattern = regexp.MustCompile(`(?i)\b(iccid|imsi|msisdn|imei|eid)\s*([=:])\s*([a-z0-9+_-]{7,})`)
)
// IsHTTPAccessEntry identifies legacy request-traffic entries. Access traffic
// is intentionally excluded from the user diagnostic log surface.
func IsHTTPAccessEntry(entry Entry) bool {
if strings.EqualFold(strings.TrimSpace(entry.Message), "http request") {
return true
}
category, _ := entry.Fields["category"].(string)
return strings.EqualFold(strings.TrimSpace(category), "http_access")
}
// SanitizeEntry also protects records that were persisted by an older build
// before central redaction was introduced.
func SanitizeEntry(entry Entry) Entry {
entry.Message = RedactString(entry.Message)
entry.Caller = RedactString(entry.Caller)
if entry.Fields != nil {
entry.Fields = sanitizeMap(entry.Fields)
}
return entry
}
// RedactString masks common telecom identities while retaining enough of the
// suffix to correlate repeated events in an exported diagnostic log.
func RedactString(value string) string {
if value == "" {
return value
}
value = labeledIdentityPattern.ReplaceAllStringFunc(value, func(match string) string {
parts := labeledIdentityPattern.FindStringSubmatch(match)
return parts[1] + parts[2] + maskToken(parts[3])
})
value = sipIdentityPattern.ReplaceAllStringFunc(value, func(match string) string {
parts := sipIdentityPattern.FindStringSubmatch(match)
domain := parts[3]
return parts[1] + ":" + maskToken(parts[2]) + domain
})
value = internationalPhonePattern.ReplaceAllStringFunc(value, maskToken)
return longDigitsPattern.ReplaceAllStringFunc(value, maskToken)
}
func sanitizeRecord(record slog.Record) slog.Record {
clean := slog.NewRecord(record.Time, record.Level, RedactString(record.Message), record.PC)
record.Attrs(func(attr slog.Attr) bool {
clean.AddAttrs(sanitizeAttr(attr))
return true
})
return clean
}
func sanitizeAttrs(attrs []slog.Attr) []slog.Attr {
clean := make([]slog.Attr, 0, len(attrs))
for _, attr := range attrs {
clean = append(clean, sanitizeAttr(attr))
}
return clean
}
func sanitizeAttr(attr slog.Attr) slog.Attr {
attr.Value = attr.Value.Resolve()
if attr.Equal(slog.Attr{}) {
return attr
}
if sensitiveKey(attr.Key) {
return slog.String(attr.Key, maskToken(valueText(attr.Value.Any())))
}
if attr.Value.Kind() == slog.KindGroup {
children := attr.Value.Group()
return slog.Group(attr.Key, attrsToAny(sanitizeAttrs(children))...)
}
switch attr.Value.Kind() {
case slog.KindString:
return slog.String(attr.Key, RedactString(attr.Value.String()))
case slog.KindAny:
return slog.Any(attr.Key, sanitizeAny(attr.Value.Any(), attr.Key))
default:
return attr
}
}
func attrsToAny(attrs []slog.Attr) []any {
values := make([]any, len(attrs))
for index := range attrs {
values[index] = attrs[index]
}
return values
}
func sanitizeAny(value any, key string) any {
if value == nil {
return nil
}
if sensitiveKey(key) {
return maskToken(valueText(value))
}
switch typed := value.(type) {
case error:
return RedactString(typed.Error())
case string:
return RedactString(typed)
case []byte:
return RedactString(string(typed))
case json.RawMessage:
var decoded any
if json.Unmarshal(typed, &decoded) == nil {
return sanitizeAny(decoded, key)
}
return RedactString(string(typed))
case map[string]any:
return sanitizeMap(typed)
case []any:
result := make([]any, len(typed))
for index := range typed {
result[index] = sanitizeAny(typed[index], key)
}
return result
case time.Time, time.Duration:
return value
}
rv := reflect.ValueOf(value)
if rv.IsValid() && (rv.Kind() == reflect.Map || rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array || rv.Kind() == reflect.Struct || rv.Kind() == reflect.Pointer) {
if raw, err := json.Marshal(value); err == nil {
var decoded any
if json.Unmarshal(raw, &decoded) == nil {
return sanitizeAny(decoded, key)
}
}
}
if stringer, ok := value.(fmt.Stringer); ok {
return RedactString(stringer.String())
}
return value
}
func sanitizeMap(source map[string]any) map[string]any {
result := make(map[string]any, len(source))
for key, value := range source {
result[key] = sanitizeAny(value, key)
}
return result
}
func sensitiveKey(key string) bool {
normalized := strings.Map(func(r rune) rune {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return unicode.ToLower(r)
}
return -1
}, key)
if strings.Contains(normalized, "password") || strings.Contains(normalized, "passwd") ||
strings.Contains(normalized, "secret") || strings.Contains(normalized, "token") ||
strings.Contains(normalized, "cookie") || strings.Contains(normalized, "authorization") ||
strings.Contains(normalized, "privateidentity") || strings.Contains(normalized, "publicidentity") ||
strings.Contains(normalized, "associatednumber") || strings.Contains(normalized, "sipuri") {
return true
}
switch normalized {
case "iccid", "imsi", "imei", "eid", "supi", "suci", "msisdn", "phone", "phonenumber",
"number", "caller", "called", "callee", "recipient", "peer", "from", "to":
return true
default:
return false
}
}
func valueText(value any) string {
if value == nil {
return ""
}
if err, ok := value.(error); ok {
return err.Error()
}
return fmt.Sprint(value)
}
func maskToken(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "[REDACTED]"
}
runes := []rune(value)
digits := make([]rune, 0, 4)
for index := len(runes) - 1; index >= 0 && len(digits) < 4; index-- {
if unicode.IsDigit(runes[index]) {
digits = append(digits, runes[index])
}
}
if len(digits) == 0 {
return "[REDACTED]"
}
for left, right := 0, len(digits)-1; left < right; left, right = left+1, right-1 {
digits[left], digits[right] = digits[right], digits[left]
}
return "[REDACTED:" + string(digits) + "]"
}
+33 -1
View File
@@ -2,6 +2,7 @@ package server
import ( import (
"context" "context"
"log/slog"
"net" "net"
"net/http" "net/http"
"strings" "strings"
@@ -21,6 +22,20 @@ func (s *Server) recordAudit(
outcome string, outcome string,
remoteAddr string, remoteAddr string,
) { ) {
level := slog.LevelInfo
if !strings.EqualFold(strings.TrimSpace(outcome), "success") {
level = slog.LevelWarn
}
if s.logger != nil {
s.logger.Log(ctx, level, "user operation",
"category", auditLogCategory(action),
"event", action,
"actor", actor,
"entity_type", entityType,
"entity_id", entityID,
"outcome", outcome,
)
}
if s.store == nil { if s.store == nil {
return return
} }
@@ -34,7 +49,24 @@ func (s *Server) recordAudit(
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
}) })
if err != nil { if err != nil {
s.logger.Warn("write audit event failed", "action", action, "error", err) s.logger.Warn("write audit event failed", "category", "system", "action", action, "raw_error", err)
}
}
func auditLogCategory(action string) string {
action = strings.ToLower(strings.TrimSpace(action))
switch {
case strings.Contains(action, ".sms") || strings.HasPrefix(action, "sms."):
return "sms"
case strings.Contains(action, ".call") || strings.HasPrefix(action, "call."):
return "call"
case strings.Contains(action, "vowifi") || strings.Contains(action, "ims"):
return "vowifi"
case strings.Contains(action, "device") || strings.Contains(action, "esim") ||
strings.Contains(action, ".at.") || strings.Contains(action, ".ussd"):
return "hardware"
default:
return "operation"
} }
} }
+10
View File
@@ -130,6 +130,11 @@ func (s *Server) handleCallAction(w http.ResponseWriter, r *http.Request, config
} }
} }
if err != nil { if err != nil {
s.logger.Warn("VoWiFi call operation failed",
"category", "call", "event", "call."+action,
"device_id", config.ID, "number", number, "call_id", callID,
"transport", transport, "raw_error", err,
)
writeError(w, http.StatusBadGateway, "vowifi_call_failed", err.Error()) writeError(w, http.StatusBadGateway, "vowifi_call_failed", err.Error())
return true return true
} }
@@ -156,6 +161,11 @@ func (s *Server) handleCallAction(w http.ResponseWriter, r *http.Request, config
return true return true
} }
if !strings.EqualFold(strings.TrimSpace(response.Final), "OK") { if !strings.EqualFold(strings.TrimSpace(response.Final), "OK") {
s.logger.Warn("cellular call operation rejected",
"category", "call", "event", "call."+action,
"device_id", config.ID, "number", number, "transport", transport,
"modem_final", response.Final, "raw_response", response.Text(),
)
writeError(w, http.StatusBadGateway, "call_rejected", "modem did not accept the call action") writeError(w, http.StatusBadGateway, "call_rejected", "modem did not accept the call action")
return true return true
} }
+12 -2
View File
@@ -98,11 +98,21 @@ func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCa
if notification.Time.IsZero() { if notification.Time.IsZero() {
notification.Time = time.Now().UTC() notification.Time = time.Now().UTC()
} }
if s.logger != nil {
s.logger.Info("incoming call detected",
"category", "call",
"event", "call.incoming",
"device_id", notification.DeviceID,
"caller", notification.Caller,
"called", notification.Called,
"transport", notification.Environment,
)
}
dedupKey := fmt.Sprintf("%s:%s", notification.DeviceID, notification.Caller) dedupKey := fmt.Sprintf("%s:%s", notification.DeviceID, notification.Caller)
if shouldSuppressDuplicateCall(dedupKey, notification.Time, callDeduplicationWindow) { if shouldSuppressDuplicateCall(dedupKey, notification.Time, callDeduplicationWindow) {
if s.logger != nil { if s.logger != nil {
s.logger.Debug("suppressed duplicate incoming call notification", "device_id", notification.DeviceID, "caller", notification.Caller) s.logger.Debug("suppressed duplicate incoming call notification", "category", "call", "device_id", notification.DeviceID, "caller", notification.Caller)
} }
return return
} }
@@ -137,7 +147,7 @@ func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCa
} }
if err := sendCallNotification(destCtx, channel, config, notification); err != nil { if err := sendCallNotification(destCtx, channel, config, notification); err != nil {
if s.logger != nil { if s.logger != nil {
s.logger.Warn("send incoming call notification", "channel", channel, "device_id", notification.DeviceID, "caller", notification.Caller, "error", err) s.logger.Warn("send incoming call notification", "category", "call", "channel", channel, "device_id", notification.DeviceID, "caller", notification.Caller, "raw_error", err)
} }
} }
} }
+17 -4
View File
@@ -1002,6 +1002,11 @@ func (s *Server) handleVoWiFiReconnect(
} }
func (s *Server) writeVoWiFiError(w http.ResponseWriter, err error) { func (s *Server) writeVoWiFiError(w http.ResponseWriter, err error) {
s.logger.Warn("VoWiFi operation failed",
"category", "vowifi",
"event", "vowifi.operation_failed",
"raw_error", err,
)
switch { switch {
case errors.Is(err, vowifiruntime.ErrNotRegistered): case errors.Is(err, vowifiruntime.ErrNotRegistered):
writeError(w, http.StatusServiceUnavailable, "vowifi_device_unavailable", "the configured device has no VoWiFi runtime") writeError(w, http.StatusServiceUnavailable, "vowifi_device_unavailable", "the configured device has no VoWiFi runtime")
@@ -1012,7 +1017,6 @@ func (s *Server) writeVoWiFiError(w http.ResponseWriter, err error) {
case errors.Is(err, vowifi.ErrNotRunning): case errors.Is(err, vowifi.ErrNotRunning):
writeError(w, http.StatusConflict, "vowifi_not_running", "VoWiFi is not running") writeError(w, http.StatusConflict, "vowifi_not_running", "VoWiFi is not running")
default: default:
s.logger.Warn("VoWiFi action rejected", "error", err)
writeError(w, http.StatusBadGateway, "vowifi_error", err.Error()) writeError(w, http.StatusBadGateway, "vowifi_error", err.Error())
} }
} }
@@ -1070,6 +1074,13 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo
text += "\n" text += "\n"
} }
text += commandErr.Final text += commandErr.Final
s.logger.Warn("AT command rejected by modem",
"category", "hardware",
"event", "hardware.at_rejected",
"device_id", id,
"modem_final", commandErr.Final,
"raw_response", text,
)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{ "data": map[string]any{
"response": text, "response": text,
@@ -1509,6 +1520,11 @@ func (s *Server) requirePhysicalDevice(w http.ResponseWriter, present bool) bool
} }
func (s *Server) writeDeviceError(w http.ResponseWriter, err error) { func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
s.logger.Warn("hardware operation failed",
"category", "hardware",
"event", "hardware.operation_failed",
"raw_error", device.HardwareErrorDetail(err),
)
switch { switch {
case errors.Is(err, device.ErrNotFound): case errors.Is(err, device.ErrNotFound):
writeError(w, http.StatusNotFound, "device_not_found", "device was not found or is no longer present") writeError(w, http.StatusNotFound, "device_not_found", "device was not found or is no longer present")
@@ -1547,9 +1563,6 @@ func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
case errors.Is(err, context.Canceled): case errors.Is(err, context.Canceled):
writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled") writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled")
default: default:
// Preserve the hardware failure reason in the operator-visible log while
// keeping AT payloads and long APDU material out of it.
s.logger.Warn("device operation failed", "error", device.HardwareErrorDetail(err))
writeError(w, http.StatusBadGateway, "modem_error", "the device operation failed") writeError(w, http.StatusBadGateway, "modem_error", "the device operation failed")
} }
} }
+31 -5
View File
@@ -152,7 +152,24 @@ func (s *Server) writeUIPreferences(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
if !requireMethod(w, r, http.MethodGet) { if r.Method == http.MethodDelete {
clearedAt := time.Now().UTC()
if s.logs != nil {
s.logs.Clear()
}
deleted, err := s.store.ClearLogEvents(r.Context(), clearedAt)
if err != nil {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{"cleared": true, "deleted": deleted},
})
return
}
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET, DELETE")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return return
} }
limit, err := strconv.Atoi(r.URL.Query().Get("lines")) limit, err := strconv.Atoi(r.URL.Query().Get("lines"))
@@ -170,7 +187,9 @@ func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
// backs the live stream). // backs the live stream).
entries := []loghub.Entry{} entries := []loghub.Entry{}
if s.store != nil { if s.store != nil {
events, err := s.store.ListLogEvents(r.Context(), store.LogFilter{Limit: limit}) events, err := s.store.ListLogEvents(r.Context(), store.LogFilter{
Limit: limit, ExcludeMessage: "http request",
})
if err != nil { if err != nil {
s.writeStoreError(w, err) s.writeStoreError(w, err)
return return
@@ -179,11 +198,17 @@ func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
if storedLogLevel(event.Level) < minimum { if storedLogLevel(event.Level) < minimum {
continue continue
} }
entry := storedLogToEntry(event) entry := loghub.SanitizeEntry(storedLogToEntry(event))
if loghub.IsHTTPAccessEntry(entry) {
continue
}
if search != "" && !storedLogContains(entry, search) { if search != "" && !storedLogContains(entry, search) {
continue continue
} }
entries = append(entries, entry) entries = append(entries, entry)
if len(entries) == limit {
break
}
} }
// ListLogEvents is newest-first; present chronologically. // ListLogEvents is newest-first; present chronologically.
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 { for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
@@ -283,7 +308,8 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
if logLevel(entry.Level) < minimum { entry = loghub.SanitizeEntry(entry)
if loghub.IsHTTPAccessEntry(entry) || logLevel(entry.Level) < minimum {
continue continue
} }
if _, err := w.Write([]byte("event: log\ndata: ")); err != nil { if _, err := w.Write([]byte("event: log\ndata: ")); err != nil {
@@ -308,7 +334,7 @@ func logLevel(value string) slog.Level {
return slog.LevelError return slog.LevelError
case "warn", "warning": case "warn", "warning":
return slog.LevelWarn return slog.LevelWarn
case "debug": case "debug", "all", "":
return slog.LevelDebug return slog.LevelDebug
default: default:
return slog.LevelInfo return slog.LevelInfo
+40
View File
@@ -0,0 +1,40 @@
package server
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"vocat/internal/loghub"
"vocat/internal/store"
)
func TestHandleLogHistoryDeleteClearsMemoryAndDatabase(t *testing.T) {
server := newSettingsTestServer(t)
hub := loghub.New(slog.NewTextHandler(io.Discard, nil), 100)
server.logs = hub
server.logger = slog.New(hub)
server.logger.Info("memory log")
if _, err := server.store.AppendLogEvent(context.Background(), store.LogEvent{
Level: "info", Message: "persisted log",
}); err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodDelete, "/api/logs/history", nil)
server.handleLogHistory(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
if history := hub.History(10, slog.LevelDebug, ""); len(history) != 0 {
t.Fatalf("memory history after clear = %#v", history)
}
count, err := server.store.CountLogEvents(context.Background())
if err != nil || count != 0 {
t.Fatalf("persisted count after clear = %d, %v", count, err)
}
}
+13 -3
View File
@@ -36,13 +36,17 @@ func parseLoggingConfig(config loggingConfig) (loggingConfig, error) {
if config.Count < 1 { if config.Count < 1 {
config.Count = 10000 config.Count = 10000
} }
if config.Count > store.MaxLogEvents {
config.Count = store.MaxLogEvents
}
if config.Days < 1 { if config.Days < 1 {
config.Days = 30 config.Days = 30
} }
return config, nil return config, nil
} }
// loadLoggingConfig reads the persisted retention policy, defaulting to unlimited. // loadLoggingConfig reads the persisted retention policy. "unlimited" means
// no user-selected limit below the global 10,000-row hard ceiling.
func (s *Server) loadLoggingConfig(ctx context.Context) loggingConfig { func (s *Server) loadLoggingConfig(ctx context.Context) loggingConfig {
config := defaultLoggingConfig() config := defaultLoggingConfig()
setting, err := s.store.AppSetting(ctx, loggingSettingKey) setting, err := s.store.AppSetting(ctx, loggingSettingKey)
@@ -64,13 +68,17 @@ func (s *Server) applyLogRetention(ctx context.Context) error {
switch config.Mode { switch config.Mode {
case "days": case "days":
cutoff := time.Now().UTC().Add(-time.Duration(config.Days) * 24 * time.Hour) cutoff := time.Now().UTC().Add(-time.Duration(config.Days) * 24 * time.Hour)
_, err := s.store.PruneLogEvents(ctx, cutoff) if _, err := s.store.PruneLogEvents(ctx, cutoff); err != nil {
return err
}
_, err := s.store.PruneLogEventsToCount(ctx, store.MaxLogEvents)
return err return err
case "count": case "count":
_, err := s.store.PruneLogEventsToCount(ctx, config.Count) _, err := s.store.PruneLogEventsToCount(ctx, config.Count)
return err return err
default: default:
return nil _, err := s.store.PruneLogEventsToCount(ctx, store.MaxLogEvents)
return err
} }
} }
@@ -116,6 +124,7 @@ func (s *Server) handleLoggingSettings(w http.ResponseWriter, r *http.Request) {
"count": config.Count, "count": config.Count,
"days": config.Days, "days": config.Days,
"stored_logs": stored, "stored_logs": stored,
"max_logs": store.MaxLogEvents,
}, },
}) })
case http.MethodPut: case http.MethodPut:
@@ -152,6 +161,7 @@ func (s *Server) handleLoggingSettings(w http.ResponseWriter, r *http.Request) {
"count": config.Count, "count": config.Count,
"days": config.Days, "days": config.Days,
"stored_logs": stored, "stored_logs": stored,
"max_logs": store.MaxLogEvents,
}, },
}) })
default: default:
+10
View File
@@ -199,6 +199,16 @@ func TestHandleLoggingSettingsRoundTripAndEnforceCount(t *testing.T) {
} }
} }
func TestLoggingCountIsClampedToHardLimit(t *testing.T) {
config, err := parseLoggingConfig(loggingConfig{Mode: "count", Count: store.MaxLogEvents + 500})
if err != nil {
t.Fatal(err)
}
if config.Count != store.MaxLogEvents {
t.Fatalf("count = %d, want %d", config.Count, store.MaxLogEvents)
}
}
func TestLoginLockoutViaHTTP(t *testing.T) { func TestLoginLockoutViaHTTP(t *testing.T) {
app := newTestApplication(t) app := newTestApplication(t)
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
+47 -14
View File
@@ -160,7 +160,7 @@ func New(options Options) (*Server, error) {
mux.HandleFunc("/", server.handleSPA) mux.HandleFunc("/", server.handleSPA)
server.handler = server.recoverPanics( server.handler = server.recoverPanics(
server.securityHeaders(server.accessControl(server.logRequests(mux))), server.securityHeaders(server.accessControl(server.logUserOperation(mux))),
) )
return server, nil return server, nil
} }
@@ -557,16 +557,14 @@ func requireMethod(w http.ResponseWriter, r *http.Request, allowed string) bool
return false return false
} }
type statusWriter struct { type operationStatusWriter struct {
http.ResponseWriter http.ResponseWriter
status int status int
} }
func (w *statusWriter) Unwrap() http.ResponseWriter { func (w *operationStatusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
return w.ResponseWriter
}
func (w *statusWriter) WriteHeader(status int) { func (w *operationStatusWriter) WriteHeader(status int) {
if w.status != 0 { if w.status != 0 {
return return
} }
@@ -574,25 +572,60 @@ func (w *statusWriter) WriteHeader(status int) {
w.ResponseWriter.WriteHeader(status) w.ResponseWriter.WriteHeader(status)
} }
func (s *Server) logRequests(next http.Handler) http.Handler { // logUserOperation records state-changing API actions, not request traffic.
// GET/HEAD polling, assets, health checks and the live log stream are never
// emitted, keeping the diagnostic page focused on actions a user initiated.
func (s *Server) logUserOperation(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now() if !strings.HasPrefix(r.URL.Path, "/api/") ||
writer := &statusWriter{ResponseWriter: w} r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions ||
strings.HasPrefix(r.URL.Path, "/api/auth/") || strings.HasPrefix(r.URL.Path, "/api/logs/") {
next.ServeHTTP(w, r)
return
}
writer := &operationStatusWriter{ResponseWriter: w}
next.ServeHTTP(writer, r) next.ServeHTTP(writer, r)
status := writer.status status := writer.status
if status == 0 { if status == 0 {
status = http.StatusOK status = http.StatusOK
} }
s.logger.Info( level := slog.LevelInfo
"http request", outcome := "success"
"method", r.Method, message := "user operation completed"
"path", r.URL.Path, if status >= http.StatusBadRequest {
level = slog.LevelWarn
outcome = "failed"
message = "user operation failed"
}
s.logger.Log(r.Context(), level, message,
"category", operationPathCategory(r.URL.Path),
"event", "user.operation",
"operation", strings.TrimPrefix(r.URL.Path, "/api/"),
"outcome", outcome,
"status", status, "status", status,
"duration", time.Since(startedAt),
) )
}) })
} }
func operationPathCategory(path string) string {
path = strings.ToLower(path)
switch {
case strings.Contains(path, "/sms"):
return "sms"
case strings.Contains(path, "/call"):
return "call"
case strings.Contains(path, "/vowifi") || strings.Contains(path, "/ims"):
return "vowifi"
case strings.Contains(path, "/network") || strings.Contains(path, "/operator"):
return "network"
case strings.Contains(path, "/device") || strings.Contains(path, "/esim") ||
strings.Contains(path, "/ussd") || strings.Contains(path, "/at"):
return "hardware"
default:
return "operation"
}
}
func (s *Server) securityHeaders(next http.Handler) http.Handler { func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
+28
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"io" "io"
"io/fs" "io/fs"
"log/slog"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"net/http/httptest" "net/http/httptest"
@@ -18,9 +19,36 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"vocat/internal/auth" "vocat/internal/auth"
"vocat/internal/loghub"
"vocat/internal/store" "vocat/internal/store"
) )
func TestUserOperationLoggerExcludesReadTraffic(t *testing.T) {
hub := loghub.New(slog.NewTextHandler(io.Discard, nil), 100)
server := &Server{logger: slog.New(hub)}
handler := server.logUserOperation(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/devices", nil))
if history := hub.History(10, slog.LevelDebug, ""); len(history) != 0 {
t.Fatalf("GET traffic produced diagnostic logs: %#v", history)
}
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPatch, "/api/devices/dev1/network", nil))
history := hub.History(10, slog.LevelDebug, "")
if len(history) != 1 {
t.Fatalf("mutation log count = %d, want 1", len(history))
}
if history[0].Message != "user operation completed" || history[0].Fields["category"] != "network" {
t.Fatalf("mutation log = %#v", history[0])
}
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodDelete, "/api/logs/history", nil))
if history = hub.History(10, slog.LevelDebug, ""); len(history) != 1 {
t.Fatalf("log clear endpoint produced an operation log: %#v", history)
}
}
type testApplication struct { type testApplication struct {
server *httptest.Server server *httptest.Server
client *http.Client client *http.Client
+56 -4
View File
@@ -369,16 +369,26 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
if sendErr != nil { if sendErr != nil {
data["retry_safe"] = false data["retry_safe"] = false
if result.PartsAccepted > 0 { if result.PartsAccepted > 0 {
s.logger.Warn("multipart SMS was only partially accepted",
"category", "sms", "event", "sms.submission",
"device_id", request.DeviceID, "peer", request.Phone,
"transport", "cellular_at", "parts_attempted", result.PartsAttempted,
"parts_accepted", result.PartsAccepted, "raw_error", sendErr,
)
data["warning"] = "Only part of the multipart SMS was accepted by the modem. Do not retry the whole message." data["warning"] = "Only part of the multipart SMS was accepted by the modem. Do not retry the whole message."
writeJSON(w, http.StatusAccepted, map[string]any{"data": data}) writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
return return
} }
s.logger.Warn( s.logger.Warn(
"SMS submission failed after modem interaction", "SMS submission failed after modem interaction",
"category", "sms",
"event", "sms.submission",
"device_id", request.DeviceID, "device_id", request.DeviceID,
"peer", request.Phone,
"transport", "cellular_at",
"parts_attempted", result.PartsAttempted, "parts_attempted", result.PartsAttempted,
"parts_accepted", result.PartsAccepted, "parts_accepted", result.PartsAccepted,
"error", sendErr, "raw_error", sendErr,
) )
writeJSON(w, http.StatusBadGateway, map[string]any{ writeJSON(w, http.StatusBadGateway, map[string]any{
"error": apiError{ "error": apiError{
@@ -390,6 +400,12 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
return return
} }
if !result.AllPartsAccepted { if !result.AllPartsAccepted {
s.logger.Warn("SMS submission was not confirmed",
"category", "sms", "event", "sms.submission",
"device_id", request.DeviceID, "peer", request.Phone,
"transport", "cellular_at", "modem_final", result.ModemFinal,
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
)
writeJSON(w, http.StatusBadGateway, map[string]any{ writeJSON(w, http.StatusBadGateway, map[string]any{
"error": apiError{ "error": apiError{
Code: "sms_submission_unconfirmed", Code: "sms_submission_unconfirmed",
@@ -399,6 +415,11 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
}) })
return return
} }
s.logger.Info("SMS submission accepted",
"category", "sms", "event", "sms.submission",
"device_id", request.DeviceID, "peer", request.Phone,
"transport", "cellular_at", "parts", result.PartsAccepted,
)
writeJSON(w, http.StatusAccepted, map[string]any{"data": data}) writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
} }
@@ -475,6 +496,12 @@ func (s *Server) writeIMSSMSSendResult(
"outcome": smsSendOutcome(result.AllPartsAccepted, result.PartsAccepted, result.PartsTotal, result.DeliveryConfirmed), "outcome": smsSendOutcome(result.AllPartsAccepted, result.PartsAccepted, result.PartsTotal, result.DeliveryConfirmed),
} }
if sendErr != nil { if sendErr != nil {
s.logger.Warn("IMS SMS submission failed",
"category", "sms", "event", "sms.submission",
"device_id", deviceID, "peer", result.To, "transport", "ims",
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
"raw_error", sendErr,
)
data["retry_safe"] = false data["retry_safe"] = false
data["warning"] = sendErr.Error() data["warning"] = sendErr.Error()
if result.PartsAccepted == 0 { if result.PartsAccepted == 0 {
@@ -489,6 +516,11 @@ func (s *Server) writeIMSSMSSendResult(
} }
} }
if !result.AllPartsAccepted && result.PartsAccepted == 0 { if !result.AllPartsAccepted && result.PartsAccepted == 0 {
s.logger.Warn("IMS SMS submission was not confirmed",
"category", "sms", "event", "sms.submission",
"device_id", deviceID, "peer", result.To, "transport", "ims",
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
)
writeJSON(w, http.StatusBadGateway, map[string]any{ writeJSON(w, http.StatusBadGateway, map[string]any{
"error": apiError{ "error": apiError{
Code: "ims_sms_submission_unconfirmed", Code: "ims_sms_submission_unconfirmed",
@@ -498,6 +530,19 @@ func (s *Server) writeIMSSMSSendResult(
}) })
return return
} }
if result.AllPartsAccepted {
s.logger.Info("IMS SMS submission accepted",
"category", "sms", "event", "sms.submission",
"device_id", deviceID, "peer", result.To, "transport", "ims",
"parts", result.PartsAccepted,
)
} else {
s.logger.Warn("multipart IMS SMS was only partially accepted",
"category", "sms", "event", "sms.submission",
"device_id", deviceID, "peer", result.To, "transport", "ims",
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
)
}
writeJSON(w, http.StatusAccepted, map[string]any{"data": data}) writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
} }
@@ -653,7 +698,7 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
"delivery_status": message.DeliveryStatus, "delivery_status": message.DeliveryStatus,
"data_coding_scheme": message.DataCodingScheme, "data_coding_scheme": message.DataCodingScheme,
}) })
_, saveErr := s.store.SaveSMSMessage(ctx, store.SMSMessage{ saved, saveErr := s.store.SaveSMSMessage(ctx, store.SMSMessage{
MessageID: messageID, MessageID: messageID,
DeviceID: config.ID, DeviceID: config.ID,
ModemIMEI: modemIMEI, ModemIMEI: modemIMEI,
@@ -670,7 +715,14 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
Extra: extra, Extra: extra,
}) })
if saveErr != nil { if saveErr != nil {
s.logger.Warn("persist modem SMS failed", "device_id", config.ID, "error", saveErr) s.logger.Warn("persist modem SMS failed", "category", "sms", "device_id", config.ID, "raw_error", saveErr)
} else if saved.Direction == "inbound" && saved.CreatedAt.Unix() == saved.UpdatedAt.Unix() {
s.logger.Info("cellular SMS received",
"category", "sms", "event", "sms.received",
"device_id", config.ID, "peer", saved.Peer,
"transport", "cellular_at", "encoding", message.Encoding,
"parts", saved.PartsTotal,
)
} }
} }
} }
@@ -770,6 +822,6 @@ func (s *Server) writeStoreError(w http.ResponseWriter, err error) {
writeError(w, http.StatusNotFound, "not_found", "the requested record was not found") writeError(w, http.StatusNotFound, "not_found", "the requested record was not found")
return return
} }
s.logger.Error("database operation failed", "error", err) s.logger.Error("database operation failed", "category", "system", "event", "store.operation_failed", "raw_error", err)
writeError(w, http.StatusInternalServerError, "database_error", "the database operation failed") writeError(w, http.StatusInternalServerError, "database_error", "the database operation failed")
} }
+9
View File
@@ -1017,6 +1017,15 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
if err != nil || len(logs) != 1 || logs[0].Message != "ready" { if err != nil || len(logs) != 1 || logs[0].Message != "ready" {
t.Fatalf("log filter result = %+v, %v", logs, err) t.Fatalf("log filter result = %+v, %v", logs, err)
} }
if _, err := database.AppendLogEvent(ctx, LogEvent{
Time: recent, Level: "info", Message: " HTTP REQUEST ",
}); err != nil {
t.Fatal(err)
}
logs, err = database.ListLogEvents(ctx, LogFilter{Level: "info", ExcludeMessage: "http request"})
if err != nil || len(logs) != 1 || logs[0].Message != "ready" {
t.Fatalf("excluded log filter result = %+v, %v", logs, err)
}
auditDeleted, logDeleted, err := database.PruneEvents( auditDeleted, logDeleted, err := database.PruneEvents(
ctx, ctx,
old.Add(time.Minute), old.Add(time.Minute),
+56 -2
View File
@@ -9,6 +9,10 @@ import (
"time" "time"
) )
// MaxLogEvents is the hard storage ceiling. Every new row beyond this limit
// replaces the oldest row regardless of the optional, stricter retention rule.
const MaxLogEvents = 10000
func (s *Store) AppendAuditEvent(ctx context.Context, value AuditEvent) (AuditEvent, error) { func (s *Store) AppendAuditEvent(ctx context.Context, value AuditEvent) (AuditEvent, error) {
value.Action = strings.TrimSpace(value.Action) value.Action = strings.TrimSpace(value.Action)
if value.Action == "" { if value.Action == "" {
@@ -123,6 +127,8 @@ func auditEvent(row rowScanner) (AuditEvent, error) {
} }
func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, error) { func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, error) {
s.logMu.Lock()
defer s.logMu.Unlock()
value.Level = strings.ToLower(strings.TrimSpace(value.Level)) value.Level = strings.ToLower(strings.TrimSpace(value.Level))
if value.Level == "" { if value.Level == "" {
return LogEvent{}, errors.New("log level is required") return LogEvent{}, errors.New("log level is required")
@@ -137,7 +143,18 @@ func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, e
if value.Time.IsZero() { if value.Time.IsZero() {
value.Time = time.Now().UTC() value.Time = time.Now().UTC()
} }
result, err := s.db.ExecContext(ctx, ` value.Fields = fields
if !s.logClearedAt.IsZero() && !value.Time.After(s.logClearedAt) {
// The entry was queued before a user cleared the log. Silently discard it
// so an in-flight persistence worker cannot resurrect cleared history.
return value, nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return LogEvent{}, fmt.Errorf("begin log append: %w", err)
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `
INSERT INTO log_events (event_time, level, message, caller, fields_json) INSERT INTO log_events (event_time, level, message, caller, fields_json)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`, value.Time.Unix(), value.Level, value.Message, value.Caller, string(fields)) `, value.Time.Unix(), value.Level, value.Message, value.Caller, string(fields))
@@ -148,7 +165,17 @@ func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, e
if err != nil { if err != nil {
return LogEvent{}, fmt.Errorf("read log event id: %w", err) return LogEvent{}, fmt.Errorf("read log event id: %w", err)
} }
value.Fields = fields if _, err := tx.ExecContext(ctx, `
DELETE FROM log_events
WHERE id <= COALESCE((
SELECT id FROM log_events ORDER BY id DESC LIMIT 1 OFFSET ?
), 0)
`, MaxLogEvents); err != nil {
return LogEvent{}, fmt.Errorf("enforce log event limit: %w", err)
}
if err := tx.Commit(); err != nil {
return LogEvent{}, fmt.Errorf("commit log append: %w", err)
}
return value, nil return value, nil
} }
@@ -159,6 +186,10 @@ func (s *Store) ListLogEvents(ctx context.Context, filter LogFilter) ([]LogEvent
clauses = append(clauses, `level = ?`) clauses = append(clauses, `level = ?`)
args = append(args, strings.ToLower(filter.Level)) args = append(args, strings.ToLower(filter.Level))
} }
if filter.ExcludeMessage != "" {
clauses = append(clauses, `LOWER(TRIM(message)) <> ?`)
args = append(args, strings.ToLower(strings.TrimSpace(filter.ExcludeMessage)))
}
if !filter.Since.IsZero() { if !filter.Since.IsZero() {
clauses = append(clauses, `event_time >= ?`) clauses = append(clauses, `event_time >= ?`)
args = append(args, filter.Since.UTC().Unix()) args = append(args, filter.Since.UTC().Unix())
@@ -236,6 +267,29 @@ func (s *Store) CountLogEvents(ctx context.Context) (int64, error) {
return count, nil return count, nil
} }
// ClearLogEvents permanently removes all persisted logs. Entries timestamped
// at or before clearedAt are also rejected if they were already queued by the
// asynchronous persistence worker.
func (s *Store) ClearLogEvents(ctx context.Context, clearedAt time.Time) (int64, error) {
s.logMu.Lock()
defer s.logMu.Unlock()
if clearedAt.IsZero() {
clearedAt = time.Now().UTC()
}
result, err := s.db.ExecContext(ctx, `DELETE FROM log_events`)
if err != nil {
return 0, fmt.Errorf("clear log events: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("read cleared log count: %w", err)
}
if clearedAt.After(s.logClearedAt) {
s.logClearedAt = clearedAt
}
return affected, nil
}
// PruneLogEventsToCount keeps only the newest `keep` log rows, deleting the // PruneLogEventsToCount keeps only the newest `keep` log rows, deleting the
// rest. keep <= 0 deletes everything. // rest. keep <= 0 deletes everything.
func (s *Store) PruneLogEventsToCount(ctx context.Context, keep int) (int64, error) { func (s *Store) PruneLogEventsToCount(ctx context.Context, keep int) (int64, error) {
+73
View File
@@ -0,0 +1,73 @@
package store
import (
"context"
"fmt"
"testing"
"time"
)
func TestAppendLogEventEnforcesHardLimit(t *testing.T) {
database, err := Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
defer database.Close()
if _, err := database.db.ExecContext(context.Background(), `
WITH RECURSIVE sequence(value) AS (
SELECT 1 UNION ALL SELECT value + 1 FROM sequence WHERE value <= ?
)
INSERT INTO log_events(event_time, level, message, caller, fields_json)
SELECT value, 'info', 'seed-' || value, '', '{}' FROM sequence
`, MaxLogEvents); err != nil {
t.Fatal(err)
}
if _, err := database.AppendLogEvent(context.Background(), LogEvent{
Level: "info", Message: "newest", Time: time.Now().UTC(),
}); err != nil {
t.Fatal(err)
}
count, err := database.CountLogEvents(context.Background())
if err != nil || count != MaxLogEvents {
t.Fatalf("CountLogEvents = %d, %v; want %d", count, err, MaxLogEvents)
}
logs, err := database.ListLogEvents(context.Background(), LogFilter{Limit: 1})
if err != nil || len(logs) != 1 || logs[0].Message != "newest" {
t.Fatalf("newest log = %#v, %v", logs, err)
}
}
func TestClearLogEventsRejectsAlreadyQueuedEntries(t *testing.T) {
database, err := Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
defer database.Close()
cutoff := time.Now().UTC()
if _, err := database.AppendLogEvent(context.Background(), LogEvent{
Level: "info", Message: "existing", Time: cutoff.Add(-time.Second),
}); err != nil {
t.Fatal(err)
}
deleted, err := database.ClearLogEvents(context.Background(), cutoff)
if err != nil || deleted != 1 {
t.Fatalf("ClearLogEvents = %d, %v", deleted, err)
}
late, err := database.AppendLogEvent(context.Background(), LogEvent{
Level: "info", Message: "queued-before-clear", Time: cutoff.Add(-time.Millisecond),
})
if err != nil || late.ID != 0 {
t.Fatalf("old queued append = %+v, %v", late, err)
}
if _, err := database.AppendLogEvent(context.Background(), LogEvent{
Level: "info", Message: fmt.Sprintf("new-%d", MaxLogEvents), Time: cutoff.Add(time.Millisecond),
}); err != nil {
t.Fatal(err)
}
count, err := database.CountLogEvents(context.Background())
if err != nil || count != 1 {
t.Fatalf("CountLogEvents = %d, %v; want 1", count, err)
}
}
+6 -5
View File
@@ -467,11 +467,12 @@ type LogEvent struct {
} }
type LogFilter struct { type LogFilter struct {
Level string Level string
Since time.Time ExcludeMessage string
Until time.Time Since time.Time
BeforeID int64 Until time.Time
Limit int BeforeID int64
Limit int
} }
type CardPolicy struct { type CardPolicy struct {
+4 -1
View File
@@ -8,6 +8,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time" "time"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
@@ -19,7 +20,9 @@ var ErrNotFound = errors.New("store: not found")
// Store owns the SQLite connection used by the process. // Store owns the SQLite connection used by the process.
type Store struct { type Store struct {
db *sql.DB db *sql.DB
logMu sync.Mutex
logClearedAt time.Time
} }
type Admin struct { type Admin struct {
+14
View File
@@ -146,6 +146,8 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
session.callMu.Unlock() session.callMu.Unlock()
if session.provider != nil && session.provider.config.Logger != nil { if session.provider != nil && session.provider.config.Logger != nil {
session.provider.config.Logger.Info("IMS call started", session.provider.config.Logger.Info("IMS call started",
"category", "call",
"device_id", session.request.DeviceID,
"direction", "outgoing", "direction", "outgoing",
"identity_source", identitySource, "identity_source", identitySource,
"target_scheme", strings.ToLower(strings.TrimSuffix(strings.SplitN(target, ":", 2)[0], ":")), "target_scheme", strings.ToLower(strings.TrimSuffix(strings.SplitN(target, ":", 2)[0], ":")),
@@ -228,6 +230,8 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
} else { } else {
if ackErr := session.sendRejectedInviteACK(call, response); ackErr != nil && session.provider != nil && session.provider.config.Logger != nil { if ackErr := session.sendRejectedInviteACK(call, response); ackErr != nil && session.provider != nil && session.provider.config.Logger != nil {
session.provider.config.Logger.Warn("IMS rejected INVITE ACK failed", session.provider.config.Logger.Warn("IMS rejected INVITE ACK failed",
"category", "call",
"device_id", session.request.DeviceID,
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID, "carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
"sip_status", response.StatusCode, "sip_status", response.StatusCode,
"error", safeSIPDiagnostic(ackErr.Error()), "error", safeSIPDiagnostic(ackErr.Error()),
@@ -368,6 +372,14 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
session.callMu.Lock() session.callMu.Lock()
session.calls[callID] = call session.calls[callID] = call
session.callMu.Unlock() session.callMu.Unlock()
if session.provider != nil && session.provider.config.Logger != nil {
session.provider.config.Logger.Info("IMS incoming call received",
"category", "call",
"device_id", session.request.DeviceID,
"caller", call.public.Number,
"call_id", call.public.ID,
)
}
if session.provider != nil && session.provider.config.OnIncomingCall != nil { if session.provider != nil && session.provider.config.OnIncomingCall != nil {
calledNumber := identityNumber(request.value("To")) calledNumber := identityNumber(request.value("To"))
if calledNumber == "" { if calledNumber == "" {
@@ -837,6 +849,8 @@ func (session *Session) logCallResponse(response *sipResponse, diagnostic string
return return
} }
session.provider.config.Logger.Info("IMS call response", session.provider.config.Logger.Info("IMS call response",
"category", "call",
"device_id", session.request.DeviceID,
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID, "carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
"sip_status", response.StatusCode, "sip_status", response.StatusCode,
"diagnostic", diagnostic, "diagnostic", diagnostic,
+3 -1
View File
@@ -911,7 +911,7 @@ func (session *Session) logInboundSMS(level slog.Level, message string, request
if session != nil && session.provider != nil && session.provider.config.Logger != nil { if session != nil && session.provider != nil && session.provider.config.Logger != nil {
logger = session.provider.config.Logger logger = session.provider.config.Logger
} }
base := []any{"device_id", session.request.DeviceID} base := []any{"category", "sms", "subsystem", "ims", "device_id", session.request.DeviceID}
if request != nil { if request != nil {
base = append(base, base = append(base,
"call_id", strings.TrimSpace(request.value("Call-ID")), "call_id", strings.TrimSpace(request.value("Call-ID")),
@@ -1091,6 +1091,8 @@ func (session *Session) logOutboundSMS(level slog.Level, message string, attribu
} }
plmn := strings.TrimSpace(session.request.Identity.HomeMCC) + strings.TrimSpace(session.request.Identity.HomeMNC) plmn := strings.TrimSpace(session.request.Identity.HomeMCC) + strings.TrimSpace(session.request.Identity.HomeMNC)
base := []any{ base := []any{
"category", "sms",
"subsystem", "ims",
"device_id", session.request.DeviceID, "device_id", session.request.DeviceID,
"home_plmn", plmn, "home_plmn", plmn,
"transport", session.transport, "transport", session.transport,
+10 -6
View File
@@ -11,12 +11,13 @@ import { message } from "../ui/message";
type RetentionMode = LoggingSettings["mode"]; type RetentionMode = LoggingSettings["mode"];
// 运行日志保留策略:默认不限制,可按条数或天数限制,服务端据此裁剪历史日志。 // 运行日志保留策略:默认不限制,可按条数或天数限制,服务端据此裁剪历史日志。
export function LogRetentionCard() { export function LogRetentionCard({ refreshKey = 0 }: { refreshKey?: number }) {
const { t } = useI18n(); const { t } = useI18n();
const [mode, setMode] = useState<RetentionMode>("unlimited"); const [mode, setMode] = useState<RetentionMode>("unlimited");
const [count, setCount] = useState(10000); const [count, setCount] = useState(10000);
const [days, setDays] = useState(30); const [days, setDays] = useState(30);
const [storedLogs, setStoredLogs] = useState(0); const [storedLogs, setStoredLogs] = useState(0);
const [maxLogs, setMaxLogs] = useState(10000);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -25,6 +26,7 @@ export function LogRetentionCard() {
setCount(data.count); setCount(data.count);
setDays(data.days); setDays(data.days);
setStoredLogs(data.storedLogs); setStoredLogs(data.storedLogs);
setMaxLogs(data.maxLogs || 10000);
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -41,14 +43,14 @@ export function LogRetentionCard() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [apply]); }, [apply, refreshKey]);
const save = useCallback(async () => { const save = useCallback(async () => {
setSaving(true); setSaving(true);
try { try {
const data = await updateLoggingSettings({ const data = await updateLoggingSettings({
mode, mode,
count: Math.max(1, Math.trunc(count) || 1), count: Math.min(maxLogs, Math.max(1, Math.trunc(count) || 1)),
days: Math.max(1, Math.trunc(days) || 1), days: Math.max(1, Math.trunc(days) || 1),
}); });
apply(data); apply(data);
@@ -58,7 +60,7 @@ export function LogRetentionCard() {
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [mode, count, days, apply]); }, [mode, count, days, maxLogs, apply]);
const onNumber = (setter: (value: number) => void) => (event: React.ChangeEvent<HTMLInputElement>) => { const onNumber = (setter: (value: number) => void) => (event: React.ChangeEvent<HTMLInputElement>) => {
const parsed = parseInt(event.target.value, 10); const parsed = parseInt(event.target.value, 10);
@@ -78,7 +80,7 @@ export function LogRetentionCard() {
className="w-32" className="w-32"
disabled={loading} disabled={loading}
options={[ options={[
{ value: "unlimited", label: t("不限制") }, { value: "unlimited", label: t("最多 10000 条") },
{ value: "count", label: t("按条数") }, { value: "count", label: t("按条数") },
{ value: "days", label: t("按天数") }, { value: "days", label: t("按天数") },
]} ]}
@@ -88,6 +90,7 @@ export function LogRetentionCard() {
<Input <Input
type="number" type="number"
min={1} min={1}
max={maxLogs}
value={count === 0 ? "" : count} value={count === 0 ? "" : count}
onChange={onNumber(setCount)} onChange={onNumber(setCount)}
disabled={loading} disabled={loading}
@@ -110,8 +113,9 @@ export function LogRetentionCard() {
</label> </label>
) : null} ) : null}
<span className="text-sm text-gray-400"> <span className="text-sm text-gray-400">
{t("当前已存储")} {storedLogs} {t("条")} {t("当前已存储")} {storedLogs} / {maxLogs} {t("条")}
</span> </span>
<span className="text-xs text-gray-400">{t("达到上限后自动删除最旧日志")}</span>
<div className="flex-1" /> <div className="flex-1" />
<Button <Button
size="small" size="small"
+16
View File
@@ -498,6 +498,22 @@ export const EN_DICT: Record<string, string> = {
"连接中断,正在尝试重连…": "Connection lost, reconnecting…", "连接中断,正在尝试重连…": "Connection lost, reconnecting…",
: "Logs exported", : "Logs exported",
"查看系统运行日志,支持过滤和搜索": "View system runtime logs with filtering and search", "查看系统运行日志,支持过滤和搜索": "View system runtime logs with filtering and search",
: "Device & Service Logs",
"记录硬件、驻网、WiFi Calling、短信、通话和用户操作;敏感信息已自动打码":
"Hardware, network registration, WiFi Calling, SMS, calls, and user operations. Sensitive identities are automatically redacted.",
: "Category",
: "All Services",
: "Hardware & Modem",
: "Network Registration",
: "Calls",
: "User Operations",
: "System",
"最多 10000 条": "Up to 10,000",
: "The oldest logs are automatically removed at the limit",
"此操作会永久删除服务端保存的全部日志,无法恢复。": "This permanently deletes all logs stored on the server and cannot be undone.",
"确认清空日志?": "Clear all logs?",
: "Logs cleared",
: "Failed to clear logs",
: "Resume", : "Resume",
: "Pause", : "Pause",
: "Connected", : "Connected",
+113 -26
View File
@@ -16,11 +16,13 @@ import { Switch } from "../components/ui/Switch";
import { Select } from "../components/ui/Select"; import { Select } from "../components/ui/Select";
import { Input } from "../components/ui/Input"; import { Input } from "../components/ui/Input";
import { message } from "../components/ui/message"; import { message } from "../components/ui/message";
import { confirmDialog } from "../components/ui/MessageBox";
import { LogRetentionCard } from "../components/logs/LogRetentionCard"; import { LogRetentionCard } from "../components/logs/LogRetentionCard";
const MAX_LOGS = 1000; const MAX_LOGS = 1000;
type Level = "all" | "debug" | "info" | "warn" | "error"; type Level = "all" | "debug" | "info" | "warn" | "error";
type Category = "all" | "hardware" | "network" | "vowifi" | "sms" | "call" | "operation" | "system";
const LEVEL_OPTIONS: { value: Level; label: string }[] = [ const LEVEL_OPTIONS: { value: Level; label: string }[] = [
{ value: "all", label: "全部" }, { value: "all", label: "全部" },
@@ -30,6 +32,17 @@ const LEVEL_OPTIONS: { value: Level; label: string }[] = [
{ value: "error", label: "ERROR" }, { value: "error", label: "ERROR" },
]; ];
const CATEGORY_OPTIONS: { value: Category; label: string }[] = [
{ value: "all", label: "全部业务" },
{ value: "hardware", label: "硬件与模块" },
{ value: "network", label: "驻网" },
{ value: "vowifi", label: "WiFi Calling" },
{ value: "sms", label: "短信" },
{ value: "call", label: "通话" },
{ value: "operation", label: "用户操作" },
{ value: "system", label: "系统错误" },
];
function levelColor(level: string): string { function levelColor(level: string): string {
switch (level.toLowerCase()) { switch (level.toLowerCase()) {
case "debug": case "debug":
@@ -52,6 +65,42 @@ function fieldsText(fields: LogEntry["fields"]): string {
return typeof fields === "string" ? fields : JSON.stringify(fields); return typeof fields === "string" ? fields : JSON.stringify(fields);
} }
function logFields(entry: LogEntry): Record<string, unknown> {
return entry.fields && typeof entry.fields === "object" ? entry.fields : {};
}
function logCategory(entry: LogEntry): Exclude<Category, "all"> {
const explicit = String(logFields(entry).category ?? "").toLowerCase();
if (CATEGORY_OPTIONS.some((item) => item.value === explicit && item.value !== "all")) {
return explicit as Exclude<Category, "all">;
}
const text = `${entry.message} ${fieldsText(entry.fields)}`.toLowerCase();
if (/\bsms\b|短信|tpdu|rp-data|rpdu/.test(text)) return "sms";
if (/incoming call|\bcall\b|invite|来电|通话/.test(text)) return "call";
if (/vowifi|wi-?fi calling|\bims\b|\bike\b|epdg|ipsec/.test(text)) return "vowifi";
if (/registration|operator|network|驻网|注册网络/.test(text)) return "network";
if (/device|modem|hardware|sim|uicc|esim|qmi|串口|模块|设备/.test(text)) return "hardware";
if (/operation|audit|setting|操作/.test(text)) return "operation";
return "system";
}
function categoryColor(category: Exclude<Category, "all">): string {
switch (category) {
case "hardware": return "bg-cyan-500/15 text-cyan-300";
case "network": return "bg-emerald-500/15 text-emerald-300";
case "vowifi": return "bg-sky-500/15 text-sky-300";
case "sms": return "bg-violet-500/15 text-violet-300";
case "call": return "bg-pink-500/15 text-pink-300";
case "operation": return "bg-amber-500/15 text-amber-300";
default: return "bg-gray-500/20 text-gray-300";
}
}
function isHTTPAccessLog(entry: LogEntry): boolean {
return entry.message.trim().toLowerCase() === "http request" ||
String(logFields(entry).category ?? "").toLowerCase() === "http_access";
}
// Reference renders a fixed YYYY-MM-DD HH:mm:ss timestamp. // Reference renders a fixed YYYY-MM-DD HH:mm:ss timestamp.
function displayTime(time: string): string { function displayTime(time: string): string {
try { try {
@@ -71,8 +120,11 @@ export default function LogsPage() {
const [paused, setPaused] = useState(false); const [paused, setPaused] = useState(false);
const [autoTail, setAutoTail] = useState(true); const [autoTail, setAutoTail] = useState(true);
const [level, setLevel] = useState<Level>("all"); const [level, setLevel] = useState<Level>("all");
const [category, setCategory] = useState<Category>("all");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [connError, setConnError] = useState(""); const [connError, setConnError] = useState("");
const [clearing, setClearing] = useState(false);
const [retentionRefreshKey, setRetentionRefreshKey] = useState(0);
const esRef = useRef<EventSource | null>(null); const esRef = useRef<EventSource | null>(null);
const logContainerRef = useRef<HTMLDivElement>(null); const logContainerRef = useRef<HTMLDivElement>(null);
@@ -80,6 +132,7 @@ export default function LogsPage() {
const levelRef = useRef<Level>("all"); const levelRef = useRef<Level>("all");
const appendLog = useCallback((entry: LogEntry) => { const appendLog = useCallback((entry: LogEntry) => {
if (isHTTPAccessLog(entry)) return;
setLogs((prev) => { setLogs((prev) => {
const next = [...prev, entry]; const next = [...prev, entry];
return next.length > MAX_LOGS ? next.slice(-MAX_LOGS) : next; return next.length > MAX_LOGS ? next.slice(-MAX_LOGS) : next;
@@ -118,7 +171,7 @@ export default function LogsPage() {
try { try {
const res = await api<LogEntry[] | { logs?: LogEntry[] }>("/logs/history?lines=500"); const res = await api<LogEntry[] | { logs?: LogEntry[] }>("/logs/history?lines=500");
const list = Array.isArray(res) ? res : (res?.logs ?? []); const list = Array.isArray(res) ? res : (res?.logs ?? []);
setLogs(list.slice(-MAX_LOGS)); setLogs(list.filter((entry) => !isHTTPAccessLog(entry)).slice(-MAX_LOGS));
} catch { } catch {
/* 历史回填失败不阻塞实时流 */ /* 历史回填失败不阻塞实时流 */
} finally { } finally {
@@ -164,13 +217,34 @@ export default function LogsPage() {
} }
}, [connect]); }, [connect]);
const clearLogs = useCallback(() => setLogs([]), []); const clearLogs = useCallback(async () => {
const confirmed = await confirmDialog(
t("此操作会永久删除服务端保存的全部日志,无法恢复。"),
t("确认清空日志?"),
{ type: "warning", confirmText: t("清空"), cancelText: t("取消") },
);
if (!confirmed) return;
setClearing(true);
try {
await api<{ cleared: boolean; deleted: number }>("/logs/history", { method: "DELETE" });
setLogs([]);
setRetentionRefreshKey((value) => value + 1);
message.success(t("日志已清空"));
} catch (error) {
message.error(error instanceof Error ? error.message : t("清空日志失败"));
} finally {
setClearing(false);
}
}, [t]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
let list = logs; let list = logs;
if (level !== "all") { if (level !== "all") {
list = list.filter((e) => e.level.toLowerCase() === level.toLowerCase()); list = list.filter((e) => e.level.toLowerCase() === level.toLowerCase());
} }
if (category !== "all") {
list = list.filter((entry) => logCategory(entry) === category);
}
if (search.trim()) { if (search.trim()) {
const q = search.toLowerCase(); const q = search.toLowerCase();
list = list.filter( list = list.filter(
@@ -181,14 +255,14 @@ export default function LogsPage() {
); );
} }
return list; return list;
}, [logs, level, search]); }, [logs, level, category, search]);
const exportLogs = useCallback(() => { const exportLogs = useCallback(() => {
const text = filtered const text = filtered
.map((v) => { .map((v) => {
const time = new Date(v.time).toLocaleString(); const time = new Date(v.time).toLocaleString();
const fields = v.fields ? ` ${fieldsText(v.fields)}` : ""; const fields = v.fields ? ` ${fieldsText(v.fields)}` : "";
return `[${time}] ${v.level.toUpperCase().padEnd(5)} ${v.caller ?? ""} ${v.message}${fields}`; return `[${time}] ${v.level.toUpperCase().padEnd(5)} [${logCategory(v)}] ${v.caller ?? ""} ${v.message}${fields}`;
}) })
.join("\n"); .join("\n");
const blob = new Blob([text], { type: "text/plain" }); const blob = new Blob([text], { type: "text/plain" });
@@ -204,8 +278,8 @@ export default function LogsPage() {
return ( return (
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<PageHeader <PageHeader
title={t("实时日志")} title={t("设备与业务日志")}
subtitle={t("查看系统运行日志,支持过滤和搜索")} subtitle={t("记录硬件、驻网、WiFi Calling、短信、通话和用户操作;敏感信息已自动打码")}
actions={ actions={
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button <Button
@@ -216,7 +290,7 @@ export default function LogsPage() {
> >
{paused ? t("继续") : t("暂停")} {paused ? t("继续") : t("暂停")}
</Button> </Button>
<Button onClick={clearLogs} className="!border-0 flex-1 justify-center sm:flex-none" icon={<DeleteRegular />}> <Button loading={clearing} onClick={clearLogs} className="!border-0 flex-1 justify-center sm:flex-none" icon={<DeleteRegular />}>
{t("清空")} {t("清空")}
</Button> </Button>
<Button onClick={exportLogs} variant="primary" className="!border-0 flex-1 justify-center sm:flex-none" icon={<ArrowDownloadRegular />}> <Button onClick={exportLogs} variant="primary" className="!border-0 flex-1 justify-center sm:flex-none" icon={<ArrowDownloadRegular />}>
@@ -255,6 +329,13 @@ export default function LogsPage() {
className="w-full sm:w-40" className="w-full sm:w-40"
options={LEVEL_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))} options={LEVEL_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
/> />
<Select
value={category}
onChange={(v) => setCategory(v as Category)}
placeholder={t("业务分类")}
className="w-full sm:w-44"
options={CATEGORY_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
/>
<Input <Input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
@@ -279,7 +360,7 @@ export default function LogsPage() {
</div> </div>
</div> </div>
<LogRetentionCard /> <LogRetentionCard refreshKey={retentionRefreshKey} />
<div className="ui-card overflow-hidden"> <div className="ui-card overflow-hidden">
<div <div
@@ -291,24 +372,30 @@ export default function LogsPage() {
{loading ? t("等待日志...") : connected ? t("等待日志...") : t("未连接到日志流")} {loading ? t("等待日志...") : connected ? t("等待日志...") : t("未连接到日志流")}
</div> </div>
) : null} ) : null}
{filtered.map((entry, i) => ( {filtered.map((entry, i) => {
<div key={i} className="py-0.5 hover:bg-white/5 px-2 -mx-2 rounded whitespace-nowrap"> const entryCategory = logCategory(entry);
<span className="text-gray-500">[{displayTime(entry.time)}]</span> const fields = logFields(entry);
<span className={cx("font-bold ml-1.5", levelColor(entry.level))}> const hasRawError = fields.raw_error !== undefined || fields.error !== undefined || fields.raw_response !== undefined;
{entry.level.toUpperCase()} return (
</span> <div key={`${entry.time}-${i}`} className="border-b border-white/5 px-2 py-2 -mx-2 last:border-0 hover:bg-white/5">
<span <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
className="text-indigo-400 inline-block max-w-48 truncate align-bottom ml-1.5" <span className="text-gray-500">[{displayTime(entry.time)}]</span>
title={entry.caller ?? ""} <span className={cx("font-bold", levelColor(entry.level))}>{entry.level.toUpperCase()}</span>
> <span className={cx("rounded px-1.5 py-0.5 text-[11px]", categoryColor(entryCategory))}>
{entry.caller ?? ""} {t(CATEGORY_OPTIONS.find((item) => item.value === entryCategory)?.label ?? "系统错误")}
</span> </span>
<span className="text-gray-100 ml-1.5">{entry.message}</span> {entry.caller ? <span className="max-w-48 truncate text-indigo-400" title={entry.caller}>{entry.caller}</span> : null}
{entry.fields ? ( <span className="break-words text-gray-100">{entry.message}</span>
<span className="text-amber-300/70 ml-1.5">{fieldsText(entry.fields)}</span> </div>
) : null} {entry.fields ? (
</div> <pre className={cx(
))} "mt-1 whitespace-pre-wrap break-all pl-2 text-xs leading-5",
hasRawError ? "border-l-2 border-red-500/60 text-red-200" : "text-amber-300/70",
)}>{typeof entry.fields === "string" ? entry.fields : JSON.stringify(entry.fields, null, 2)}</pre>
) : null}
</div>
);
})}
</div> </div>
</div> </div>
</div> </div>
+2 -1
View File
@@ -432,12 +432,13 @@ export interface SecuritySettings {
clientAllowed: boolean; clientAllowed: boolean;
} }
// 运行日志保留策略:默认不限制,可按条数或天数限制。 // 运行日志保留策略:全局硬上限 10000 条,可配置更严格的条数或天数限制。
export interface LoggingSettings { export interface LoggingSettings {
mode: "unlimited" | "count" | "days"; mode: "unlimited" | "count" | "days";
count: number; count: number;
days: number; days: number;
storedLogs: number; storedLogs: number;
maxLogs: number;
} }
export interface SystemInfo { export interface SystemInfo {