feat(timeline): fix chronology, status resolution, and component history UI

This commit is contained in:
2026-02-16 23:17:22 +03:00
parent b799228960
commit 88503d2457
17 changed files with 882 additions and 506 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"strings"
"time"
"reanimator/internal/domain"
)
@@ -17,6 +18,12 @@ type ComponentObservationMeta struct {
Location string
}
type ComponentInstallationHistoryItem struct {
AssetID string
InstalledAt time.Time
RemovedAt *time.Time
}
func NewInstallationRepository(db *sql.DB) *InstallationRepository {
return &InstallationRepository{db: db}
}
@@ -58,6 +65,56 @@ func (r *InstallationRepository) ListCurrentComponentsByAsset(ctx context.Contex
return items, nil
}
func (r *InstallationRepository) ListPreviousComponentsByAsset(ctx context.Context, assetID string) ([]domain.Component, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT c.id, c.vendor, c.model, c.vendor_serial, c.first_seen_at, c.created_at, c.updated_at
FROM installations i
JOIN parts c ON c.id = i.part_id
WHERE i.machine_id = ?
AND i.removed_at IS NOT NULL
AND i.id = (
SELECT i2.id
FROM installations i2
WHERE i2.machine_id = i.machine_id AND i2.part_id = i.part_id
ORDER BY
CASE WHEN i2.removed_at IS NULL THEN 1 ELSE 0 END DESC,
i2.removed_at DESC,
i2.installed_at DESC,
i2.created_at DESC,
i2.id DESC
LIMIT 1
)
ORDER BY c.created_at DESC`,
assetID,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]domain.Component, 0)
for rows.Next() {
var component domain.Component
var vendor sql.NullString
var model sql.NullString
var firstSeenAt sql.NullTime
if err := rows.Scan(&component.ID, &vendor, &model, &component.VendorSerial, &firstSeenAt, &component.CreatedAt, &component.UpdatedAt); err != nil {
return nil, err
}
component.Vendor = nullStringToPtr(vendor)
component.Model = nullStringToPtr(model)
component.FirstSeenAt = nullTimeToPtr(firstSeenAt)
items = append(items, component)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func (r *InstallationRepository) ListCurrentComponentIDsByAssets(ctx context.Context, assetIDs []string) (map[string][]string, error) {
result := make(map[string][]string, len(assetIDs))
if len(assetIDs) == 0 {
@@ -223,3 +280,33 @@ func (r *InstallationRepository) GetCurrentAssetIDByComponent(ctx context.Contex
}
return &assetID, nil
}
func (r *InstallationRepository) ListInstallationHistoryByComponent(ctx context.Context, componentID string) ([]ComponentInstallationHistoryItem, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT machine_id, installed_at, removed_at
FROM installations
WHERE part_id = ?
ORDER BY installed_at DESC, created_at DESC, id DESC`,
componentID,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]ComponentInstallationHistoryItem, 0)
for rows.Next() {
var item ComponentInstallationHistoryItem
var removedAt sql.NullTime
if err := rows.Scan(&item.AssetID, &item.InstalledAt, &removedAt); err != nil {
return nil, err
}
item.RemovedAt = nullTimeToPtr(removedAt)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@@ -104,28 +104,54 @@ func (r *EventRepository) List(ctx context.Context, subjectType string, subjectI
}
func (r *EventRepository) ListLatestBySubjects(ctx context.Context, subjectType string, ids []string) (map[string]domain.TimelineEvent, error) {
return r.listLatestBySubjects(ctx, subjectType, ids, nil)
}
func (r *EventRepository) ListLatestBySubjectsAndEventTypes(ctx context.Context, subjectType string, ids []string, eventTypes []string) (map[string]domain.TimelineEvent, error) {
return r.listLatestBySubjects(ctx, subjectType, ids, eventTypes)
}
func (r *EventRepository) listLatestBySubjects(ctx context.Context, subjectType string, ids []string, eventTypes []string) (map[string]domain.TimelineEvent, error) {
result := make(map[string]domain.TimelineEvent, len(ids))
if len(ids) == 0 {
return result, nil
}
placeholders := make([]string, len(ids))
args := make([]any, 0, len(ids)+1)
args := make([]any, 0, len(ids)+len(eventTypes)+2)
args = append(args, subjectType)
for i, id := range ids {
placeholders[i] = "?"
args = append(args, id)
}
eventFilter := ""
eventFilterInner := ""
if len(eventTypes) > 0 {
eventPlaceholdersOuter := make([]string, len(eventTypes))
for i, eventType := range eventTypes {
eventPlaceholdersOuter[i] = "?"
args = append(args, eventType)
}
eventPlaceholdersInner := make([]string, len(eventTypes))
for i, eventType := range eventTypes {
eventPlaceholdersInner[i] = "?"
args = append(args, eventType)
}
eventFilter = " AND t.event_type IN (" + strings.Join(eventPlaceholdersOuter, ",") + ")"
eventFilterInner = " AND t2.event_type IN (" + strings.Join(eventPlaceholdersInner, ",") + ")"
}
query := `
SELECT t.id, t.subject_id, t.event_type, t.event_time, t.machine_id, t.part_id, t.firmware_version, t.created_at
FROM timeline_events t
WHERE t.subject_type = ? AND t.subject_id IN (` + strings.Join(placeholders, ",") + `)
WHERE t.subject_type = ? AND t.subject_id IN (` + strings.Join(placeholders, ",") + `)` + eventFilter + `
AND NOT EXISTS (
SELECT 1
FROM timeline_events t2
WHERE t2.subject_type = t.subject_type
AND t2.subject_id = t.subject_id
` + eventFilterInner + `
AND (
t2.event_time > t.event_time
OR (t2.event_time = t.event_time AND t2.id > t.id)