package model import ( "encoding/json" "fmt" "sort" "time" ) // Category is the top-level grouping for an Issue. // // It is a string enum for JSON stability and friendliness. type Category string const ( CategoryPerformance Category = "Performance" CategoryMemory Category = "Memory" CategoryStorage Category = "Storage" CategoryNetwork Category = "Network" CategoryThermals Category = "Thermals" CategoryProcesses Category = "Processes" CategoryServices Category = "Services" CategoryLogs Category = "Logs" CategoryUpdates Category = "Updates" CategorySecurity Category = "Security" CategoryKubernetes Category = "Kubernetes" ) func (c Category) String() string { return string(c) } func (c Category) valid() bool { switch c { case "", CategoryPerformance, CategoryMemory, CategoryStorage, CategoryNetwork, CategoryThermals, CategoryProcesses, CategoryServices, CategoryLogs, CategoryUpdates, CategorySecurity, CategoryKubernetes: return true default: return false } } func (c Category) MarshalJSON() ([]byte, error) { if !c.valid() { return nil, fmt.Errorf("invalid category %q", string(c)) } return json.Marshal(string(c)) } func (c *Category) UnmarshalJSON(b []byte) error { var s string if err := json.Unmarshal(b, &s); err != nil { return err } tmp := Category(s) if !tmp.valid() { return fmt.Errorf("invalid category %q", s) } *c = tmp return nil } // Priority is the urgency of an Issue. // // Priorities are string enums P0..P3 where P0 is most urgent. type Priority string const ( PriorityP0 Priority = "P0" PriorityP1 Priority = "P1" PriorityP2 Priority = "P2" PriorityP3 Priority = "P3" ) func (p Priority) String() string { return string(p) } // Weight returns a numeric weight used for sorting. // Higher weight means more urgent. func (p Priority) Weight() int { switch p { case PriorityP0: return 4 case PriorityP1: return 3 case PriorityP2: return 2 case PriorityP3: return 1 default: return 0 } } func (p Priority) valid() bool { switch p { case "", PriorityP0, PriorityP1, PriorityP2, PriorityP3: return true default: return false } } func (p Priority) MarshalJSON() ([]byte, error) { if !p.valid() { return nil, fmt.Errorf("invalid priority %q", string(p)) } return json.Marshal(string(p)) } func (p *Priority) UnmarshalJSON(b []byte) error { var s string if err := json.Unmarshal(b, &s); err != nil { return err } tmp := Priority(s) if !tmp.valid() { return fmt.Errorf("invalid priority %q", s) } *p = tmp return nil } // State is the lifecycle state of an Issue. // // - Open: currently active // - Acknowledged: active but acknowledged in-memory // - Resolved: not observed for some time (resolve-after handled by store) type State string const ( StateOpen State = "Open" StateAcknowledged State = "Acknowledged" StateResolved State = "Resolved" ) func (s State) String() string { return string(s) } func (s State) valid() bool { switch s { case "", StateOpen, StateAcknowledged, StateResolved: return true default: return false } } func (s State) MarshalJSON() ([]byte, error) { if !s.valid() { return nil, fmt.Errorf("invalid state %q", string(s)) } return json.Marshal(string(s)) } func (s *State) UnmarshalJSON(b []byte) error { var str string if err := json.Unmarshal(b, &str); err != nil { return err } tmp := State(str) if !tmp.valid() { return fmt.Errorf("invalid state %q", str) } *s = tmp return nil } // Issue is the single unit of information surfaced by ControlTower. type Issue struct { ID string `json:"id"` Category Category `json:"category"` Priority Priority `json:"priority"` Title string `json:"title"` Details string `json:"details,omitempty"` Evidence map[string]string `json:"evidence,omitempty"` SuggestedFix string `json:"suggested_fix,omitempty"` State State `json:"state"` FirstSeen time.Time `json:"first_seen"` LastSeen time.Time `json:"last_seen"` } // Age returns how long the issue has existed (now - FirstSeen). // If FirstSeen is zero, Age returns 0. func (i Issue) Age(now time.Time) time.Duration { if i.FirstSeen.IsZero() { return 0 } if now.Before(i.FirstSeen) { return 0 } return now.Sub(i.FirstSeen) } // SortIssuesDefault sorts issues in-place by Priority desc, then LastSeen desc. // // This matches the default view specified in PLAN.md. func SortIssuesDefault(issues []Issue) { sort.SliceStable(issues, func(i, j int) bool { a, b := issues[i], issues[j] aw, bw := a.Priority.Weight(), b.Priority.Weight() if aw != bw { return aw > bw } if !a.LastSeen.Equal(b.LastSeen) { return a.LastSeen.After(b.LastSeen) } // Deterministic tie-breaker. return a.ID < b.ID }) }