Add asset host logs ingest and UI

This commit is contained in:
Mikhail Chusavitin
2026-03-15 21:38:20 +03:00
parent 5370c1a698
commit f4cd15f0c4
19 changed files with 1374 additions and 222 deletions

View File

@@ -0,0 +1,177 @@
package registry
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
)
type AssetEventLog struct {
ID string `json:"id"`
Source string `json:"source"`
EventTime time.Time `json:"event_time"`
Severity *string `json:"severity,omitempty"`
MessageID *string `json:"message_id,omitempty"`
Message string `json:"message"`
ComponentRef *string `json:"component_ref,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
FirstSeenAt time.Time `json:"first_seen_at"`
LastSeenAt time.Time `json:"last_seen_at"`
SeenCount int `json:"seen_count"`
RawPayload map[string]any `json:"raw_payload,omitempty"`
}
type AssetEventLogFilters struct {
Source *string
Severity *string
IsActive *bool
DateFrom *time.Time
DateTo *time.Time
Search *string
Limit int
Cursor *AssetEventLogCursor
}
type AssetEventLogCursor struct {
EventTime time.Time `json:"event_time"`
ID string `json:"id"`
}
type AssetEventLogRepository struct {
db *sql.DB
}
func NewAssetEventLogRepository(db *sql.DB) *AssetEventLogRepository {
return &AssetEventLogRepository{db: db}
}
func ParseAssetEventLogCursor(raw string) (*AssetEventLogCursor, error) {
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw))
if err != nil {
return nil, err
}
var out AssetEventLogCursor
if err := json.Unmarshal(decoded, &out); err != nil {
return nil, err
}
if out.ID == "" || out.EventTime.IsZero() {
return nil, fmt.Errorf("invalid cursor")
}
return &out, nil
}
func EncodeAssetEventLogCursor(cur AssetEventLogCursor) (string, error) {
b, err := json.Marshal(cur)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func (r *AssetEventLogRepository) ListByAsset(ctx context.Context, assetID string, filters AssetEventLogFilters) ([]AssetEventLog, *string, error) {
limit := filters.Limit
if limit <= 0 || limit > 500 {
limit = 100
}
query := `SELECT id, log_source, event_time, severity, message_id, message, component_ref, is_active, first_seen_at, last_seen_at, seen_count, raw_payload_json
FROM machine_event_logs
WHERE machine_id = ?`
args := []any{assetID}
if filters.Source != nil && strings.TrimSpace(*filters.Source) != "" {
query += ` AND log_source = ?`
args = append(args, strings.TrimSpace(*filters.Source))
}
if filters.Severity != nil && strings.TrimSpace(*filters.Severity) != "" {
query += ` AND severity = ?`
args = append(args, strings.TrimSpace(*filters.Severity))
}
if filters.IsActive != nil {
query += ` AND is_active = ?`
args = append(args, *filters.IsActive)
}
if filters.DateFrom != nil {
query += ` AND event_time >= ?`
args = append(args, filters.DateFrom.UTC())
}
if filters.DateTo != nil {
query += ` AND event_time <= ?`
args = append(args, filters.DateTo.UTC())
}
if filters.Search != nil && strings.TrimSpace(*filters.Search) != "" {
q := "%" + strings.ToLower(strings.TrimSpace(*filters.Search)) + "%"
query += ` AND (
LOWER(message) LIKE ?
OR LOWER(COALESCE(message_id, '')) LIKE ?
OR LOWER(COALESCE(component_ref, '')) LIKE ?
)`
args = append(args, q, q, q)
}
if filters.Cursor != nil {
query += ` AND (event_time < ? OR (event_time = ? AND id < ?))`
args = append(args, filters.Cursor.EventTime.UTC(), filters.Cursor.EventTime.UTC(), filters.Cursor.ID)
}
query += ` ORDER BY event_time DESC, id DESC LIMIT ?`
args = append(args, limit+1)
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, nil, err
}
defer rows.Close()
items := make([]AssetEventLog, 0, limit+1)
for rows.Next() {
var item AssetEventLog
var severity sql.NullString
var messageID sql.NullString
var componentRef sql.NullString
var isActive sql.NullBool
var rawPayload []byte
if err := rows.Scan(
&item.ID,
&item.Source,
&item.EventTime,
&severity,
&messageID,
&item.Message,
&componentRef,
&isActive,
&item.FirstSeenAt,
&item.LastSeenAt,
&item.SeenCount,
&rawPayload,
); err != nil {
return nil, nil, err
}
item.Severity = nullStringToPtr(severity)
item.MessageID = nullStringToPtr(messageID)
item.ComponentRef = nullStringToPtr(componentRef)
if isActive.Valid {
v := isActive.Bool
item.IsActive = &v
}
if len(rawPayload) > 0 {
_ = json.Unmarshal(rawPayload, &item.RawPayload)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, nil, err
}
var next *string
if len(items) > limit {
last := items[limit-1]
cursor, err := EncodeAssetEventLogCursor(AssetEventLogCursor{EventTime: last.EventTime.UTC(), ID: last.ID})
if err != nil {
return nil, nil, err
}
next = &cursor
items = items[:limit]
}
return items, next, nil
}