mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Canonicalize ago duration formatting
This commit is contained in:
parent
409c2605c2
commit
59e9edc052
11 changed files with 114 additions and 37 deletions
|
|
@ -149,9 +149,10 @@ detectors only serving as fallback coverage when the canonical store is not
|
|||
available. When that patrol-local fallback is used, it must render through the
|
||||
shared memory change presentation helper so the same heading, scope prefix, and
|
||||
change-type labels are reused instead of being rebuilt ad hoc in AI-local code.
|
||||
That shared presentation layer also owns the elapsed-time wording utility, so
|
||||
the same "time ago" phrasing stays consistent across resource, incident, and
|
||||
fallback memory summaries instead of being reformatted independently.
|
||||
That shared presentation layer also owns the elapsed-time and "ago" wording
|
||||
utilities, so the same "time ago" phrasing stays consistent across resource,
|
||||
incident, and fallback memory summaries instead of being reformatted
|
||||
independently.
|
||||
Resource-only incident context should follow the same rule: if an alert
|
||||
timeline is absent, the incident prompt path should fall back to the canonical
|
||||
unified-resource timeline rather than depending only on patrol-local change
|
||||
|
|
|
|||
|
|
@ -128,9 +128,9 @@ The same surfaces now also render recent changes through the shared
|
|||
card, so canonical timeline wording and ordering stay governed by one
|
||||
frontend feed instead of separate page-local loops.
|
||||
The canonical unified-resource change and relationship presenters now also
|
||||
share the same elapsed-time wording utility, so `observed`, `last seen`, and
|
||||
`ago` fragments stay consistent without each formatter maintaining its own
|
||||
"time ago" implementation.
|
||||
share the same elapsed-time and "ago" wording utilities, so `observed`,
|
||||
`last seen`, and `ago` fragments stay consistent without each formatter
|
||||
maintaining its own "time ago" implementation.
|
||||
The change emitter now also classifies canonical restart changes for Docker
|
||||
and Kubernetes resources when restart counters increase or uptime resets, so
|
||||
the timeline can distinguish restarts from generic state transitions instead
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ func FormatRecentChangesContext(changes []Change, includeResourcePrefix bool, he
|
|||
entry = fmt.Sprintf("%s: %s", scope, entry)
|
||||
}
|
||||
ago := time.Since(change.DetectedAt).Truncate(time.Minute)
|
||||
lines = append(lines, fmt.Sprintf("- %s (%s ago)", entry, utils.FormatDuration(ago)))
|
||||
lines = append(lines, fmt.Sprintf("- %s (%s)", entry, utils.FormatDurationAgo(ago)))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,14 @@ func TestFormatRecentChangesContext(t *testing.T) {
|
|||
|
||||
now := time.Now()
|
||||
changes := []Change{
|
||||
{
|
||||
ResourceID: "res-0",
|
||||
ResourceType: "node",
|
||||
ResourceName: "lb-01",
|
||||
ChangeType: ChangeCreated,
|
||||
Description: "came online",
|
||||
DetectedAt: now.Add(-30 * time.Second),
|
||||
},
|
||||
{
|
||||
ResourceID: "res-1",
|
||||
ResourceType: "vm",
|
||||
|
|
@ -56,6 +64,7 @@ func TestFormatRecentChangesContext(t *testing.T) {
|
|||
got := FormatRecentChangesContext(changes, true, "###")
|
||||
for _, want := range []string{
|
||||
"### Recent Changes Across Infrastructure",
|
||||
"res-0 (node): **Created** came online (just now)",
|
||||
"res-1 (vm): **Restart** restarted after maintenance (1 hour ago)",
|
||||
"cache-1 (container): **Config update** adjusted memory limit (30 minutes ago)",
|
||||
} {
|
||||
|
|
|
|||
|
|
@ -290,7 +290,7 @@ func (r *RemediationLog) FormatForContext(resourceID string, limit int) string {
|
|||
for _, rec := range records {
|
||||
ago := time.Since(rec.Timestamp)
|
||||
outcomeStr := string(rec.Outcome)
|
||||
result += "- " + utils.FormatDuration(ago) + " ago: " + rec.Problem + "\n"
|
||||
result += "- " + utils.FormatDurationAgo(ago) + ": " + rec.Problem + "\n"
|
||||
result += " Action: " + rec.Action + " (" + outcomeStr + ")\n"
|
||||
if rec.Note != "" {
|
||||
result += " Note: " + rec.Note + "\n"
|
||||
|
|
|
|||
|
|
@ -112,9 +112,9 @@ func FormatResourceChangeSummary(change ResourceChange) string {
|
|||
|
||||
ago := "recently"
|
||||
if !change.ObservedAt.IsZero() {
|
||||
ago = utils.FormatDuration(time.Since(change.ObservedAt).Truncate(time.Minute))
|
||||
ago = utils.FormatDurationAgo(time.Since(change.ObservedAt).Truncate(time.Minute))
|
||||
}
|
||||
return summary + fmt.Sprintf(" (%s ago)", ago)
|
||||
return summary + fmt.Sprintf(" (%s)", ago)
|
||||
}
|
||||
|
||||
// FormatResourceRecentChangesContext returns the canonical markdown section
|
||||
|
|
|
|||
|
|
@ -65,31 +65,64 @@ func TestDescribeChange(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestFormatResourceChangeSummary(t *testing.T) {
|
||||
change := ResourceChange{
|
||||
Kind: ChangeRestart,
|
||||
From: "running",
|
||||
To: "restarting",
|
||||
SourceType: SourcePlatformEvent,
|
||||
SourceAdapter: AdapterProxmox,
|
||||
Actor: "agent:oncall-helper",
|
||||
Reason: "Routine restart requested",
|
||||
RelatedResources: []string{"node-1"},
|
||||
ObservedAt: time.Now().Add(-2 * time.Hour),
|
||||
cases := []struct {
|
||||
name string
|
||||
change ResourceChange
|
||||
wants []string
|
||||
}{
|
||||
{
|
||||
name: "recent",
|
||||
change: ResourceChange{
|
||||
Kind: ChangeRestart,
|
||||
From: "running",
|
||||
To: "restarting",
|
||||
SourceType: SourcePlatformEvent,
|
||||
SourceAdapter: AdapterProxmox,
|
||||
Actor: "agent:oncall-helper",
|
||||
Reason: "Routine restart requested",
|
||||
RelatedResources: []string{"node-1"},
|
||||
ObservedAt: time.Now().Add(-2 * time.Hour),
|
||||
},
|
||||
wants: []string{
|
||||
"**Restart**",
|
||||
"running → restarting",
|
||||
"platform_event/proxmox_adapter",
|
||||
"actor agent:oncall-helper",
|
||||
"Routine restart requested",
|
||||
"related: node-1",
|
||||
"2 hours ago",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "just now",
|
||||
change: ResourceChange{
|
||||
Kind: ChangeConfigUpdate,
|
||||
From: "old",
|
||||
To: "new",
|
||||
SourceType: SourcePulseDiff,
|
||||
SourceAdapter: AdapterOpsAgent,
|
||||
ObservedAt: time.Now().Add(-30 * time.Second),
|
||||
},
|
||||
wants: []string{
|
||||
"just now",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
summary := FormatResourceChangeSummary(change)
|
||||
for _, want := range []string{
|
||||
"**Restart**",
|
||||
"running → restarting",
|
||||
"platform_event/proxmox_adapter",
|
||||
"actor agent:oncall-helper",
|
||||
"Routine restart requested",
|
||||
"related: node-1",
|
||||
"2 hours ago",
|
||||
} {
|
||||
if !strings.Contains(summary, want) {
|
||||
t.Fatalf("expected summary %q to contain %q", summary, want)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
summary := FormatResourceChangeSummary(tc.change)
|
||||
for _, want := range tc.wants {
|
||||
if !strings.Contains(summary, want) {
|
||||
t.Fatalf("expected summary %q to contain %q", summary, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(summary, "just now ago") {
|
||||
t.Fatalf("summary should not contain duplicated ago wording: %q", summary)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -429,8 +429,8 @@ func TestResourcePresentationsUseSharedDurationHelper(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("failed to read %s: %v", name, err)
|
||||
}
|
||||
if !strings.Contains(string(data), "utils.FormatDuration(") {
|
||||
t.Fatalf("%s must use the shared utils.FormatDuration helper", name)
|
||||
if !strings.Contains(string(data), "utils.FormatDurationAgo(") {
|
||||
t.Fatalf("%s must use the shared utils.FormatDurationAgo helper", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,10 +98,10 @@ func FormatResourceGraphContext(resource *Resource, limit int) string {
|
|||
parts = append(parts, fmt.Sprintf("confidence %s", presentation.Confidence))
|
||||
}
|
||||
if !rel.ObservedAt.IsZero() {
|
||||
parts = append(parts, fmt.Sprintf("observed %s ago", utils.FormatDuration(time.Since(rel.ObservedAt).Truncate(time.Minute))))
|
||||
parts = append(parts, fmt.Sprintf("observed %s", utils.FormatDurationAgo(time.Since(rel.ObservedAt).Truncate(time.Minute))))
|
||||
}
|
||||
if !rel.LastSeenAt.IsZero() {
|
||||
parts = append(parts, fmt.Sprintf("last seen %s ago", utils.FormatDuration(time.Since(rel.LastSeenAt).Truncate(time.Minute))))
|
||||
parts = append(parts, fmt.Sprintf("last seen %s", utils.FormatDurationAgo(time.Since(rel.LastSeenAt).Truncate(time.Minute))))
|
||||
}
|
||||
if presentation.HasMetadata {
|
||||
parts = append(parts, "metadata present")
|
||||
|
|
|
|||
|
|
@ -19,6 +19,15 @@ func FormatDuration(d time.Duration) string {
|
|||
return formatUnit(int(d.Hours()/24), "day")
|
||||
}
|
||||
|
||||
// FormatDurationAgo returns a short human-readable elapsed-time string with an "ago" suffix.
|
||||
func FormatDurationAgo(d time.Duration) string {
|
||||
formatted := FormatDuration(d)
|
||||
if formatted == "just now" {
|
||||
return formatted
|
||||
}
|
||||
return formatted + " ago"
|
||||
}
|
||||
|
||||
func formatUnit(n int, unit string) string {
|
||||
if n == 1 {
|
||||
return "1 " + unit
|
||||
|
|
|
|||
|
|
@ -29,3 +29,28 @@ func TestFormatDuration(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDurationAgo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{name: "just now", d: 30 * time.Second, want: "just now"},
|
||||
{name: "minutes", d: 2 * time.Minute, want: "2 minutes ago"},
|
||||
{name: "hours", d: 3 * time.Hour, want: "3 hours ago"},
|
||||
{name: "days", d: 48 * time.Hour, want: "2 days ago"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := FormatDurationAgo(tc.d); got != tc.want {
|
||||
t.Fatalf("FormatDurationAgo(%v) = %q, want %q", tc.d, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue