Add asset host logs ingest and UI
This commit is contained in:
177
internal/repository/registry/asset_logs.go
Normal file
177
internal/repository/registry/asset_logs.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user