From b29607e146d2b0a54e9d72ef1389501640297620 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:53:30 -0700 Subject: [PATCH] feat(status): show uptime in azd app status Add an uptime line to azd app status derived from the recorded run start time. It shows the largest two units (for example 45s, 3m12s, 1h04m, 2d03h) so the value stays short at any scale, and it is exposed as an uptime field in --json output alongside the existing startTime. Closes #497 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- cli/src/cmd/app/commands/status.go | 41 ++++++++++++++++-- cli/src/cmd/app/commands/status_test.go | 56 +++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/cli/src/cmd/app/commands/status.go b/cli/src/cmd/app/commands/status.go index 3026586e9..f630eac4c 100644 --- a/cli/src/cmd/app/commands/status.go +++ b/cli/src/cmd/app/commands/status.go @@ -33,6 +33,7 @@ type statusReport struct { PID int `json:"pid,omitempty"` DashboardURL string `json:"dashboardUrl,omitempty"` StartTime time.Time `json:"startTime,omitempty"` + Uptime string `json:"uptime,omitempty"` Services []runstate.ServiceState `json:"services,omitempty"` } @@ -41,8 +42,8 @@ func NewStatusCommand() *cobra.Command { cmd := &cobra.Command{ Use: "status", Short: "Show whether azd app run is active", - Long: `Show whether azd app run is active, along with its PID, dashboard URL, and -running services. + Long: `Show whether azd app run is active, along with its PID, dashboard URL, +uptime, and running services. Pass --watch to refresh the status on an interval until you press Ctrl+C, which is handy for keeping an eye on services while they start. The global --json flag @@ -180,13 +181,17 @@ func buildStatusReport(projectDir string) (statusReport, error) { dashboardURL = fmt.Sprintf("http://localhost:%d", port) } - return statusReport{ + report := statusReport{ Running: true, PID: st.PID, DashboardURL: dashboardURL, StartTime: st.StartTime, Services: statusServices(projectDir, st.Services), - }, nil + } + if !st.StartTime.IsZero() { + report.Uptime = formatUptime(time.Since(st.StartTime)) + } + return report, nil } func filterStatusReport(report statusReport, requested []string) (statusReport, error) { @@ -278,6 +283,9 @@ func statusTextLines(report statusReport) []string { if !report.StartTime.IsZero() { lines = append(lines, fmt.Sprintf("Started: %s", report.StartTime.Format(time.RFC3339))) } + if report.Uptime != "" { + lines = append(lines, fmt.Sprintf("Uptime: %s", report.Uptime)) + } if len(report.Services) == 0 { lines = append(lines, "Services: none") @@ -292,6 +300,31 @@ func statusTextLines(report statusReport) []string { return lines } +// formatUptime renders a duration as a compact human-readable string such as +// "45s", "3m12s", "1h04m", or "2d03h". Negative durations are clamped to zero. +// The largest two units are shown so the value stays short at any scale. +func formatUptime(d time.Duration) string { + if d < 0 { + d = 0 + } + totalSeconds := int64(d / time.Second) + days := totalSeconds / 86400 + hours := (totalSeconds % 86400) / 3600 + minutes := (totalSeconds % 3600) / 60 + seconds := totalSeconds % 60 + + switch { + case days > 0: + return fmt.Sprintf("%dd%02dh", days, hours) + case hours > 0: + return fmt.Sprintf("%dh%02dm", hours, minutes) + case minutes > 0: + return fmt.Sprintf("%dm%02ds", minutes, seconds) + default: + return fmt.Sprintf("%ds", seconds) + } +} + func formatStatusService(svc runstate.ServiceState) string { var endpoint string if svc.URL != "" { diff --git a/cli/src/cmd/app/commands/status_test.go b/cli/src/cmd/app/commands/status_test.go index cf0f712ee..bd5dfc7c1 100644 --- a/cli/src/cmd/app/commands/status_test.go +++ b/cli/src/cmd/app/commands/status_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "time" @@ -132,12 +133,67 @@ func TestBuildStatusReportRunning(t *testing.T) { assert.Contains(t, text, "Dashboard: http://localhost:40000") assert.Contains(t, text, "Services:") assert.Contains(t, text, " - api: http://localhost:8080") + assert.NotEmpty(t, report.Uptime, "expected uptime to be computed for a running app") payload, err := json.Marshal(report) require.NoError(t, err) assert.Contains(t, string(payload), `"running":true`) assert.Contains(t, string(payload), `"dashboardUrl":"http://localhost:40000"`) assert.Contains(t, string(payload), `"name":"api"`) + assert.Contains(t, string(payload), `"uptime":`) +} + +func TestStatusTextLinesUptime(t *testing.T) { + t.Run("uptime line is shown when set", func(t *testing.T) { + report := statusReport{ + Running: true, + PID: 123, + StartTime: time.Now().Add(-90 * time.Second), + Uptime: "1m30s", + } + lines := statusTextLines(report) + assert.Contains(t, lines, "Uptime: 1m30s") + + startedIdx, uptimeIdx := -1, -1 + for i, line := range lines { + switch { + case strings.HasPrefix(line, "Started: "): + startedIdx = i + case strings.HasPrefix(line, "Uptime: "): + uptimeIdx = i + } + } + require.GreaterOrEqual(t, startedIdx, 0) + require.GreaterOrEqual(t, uptimeIdx, 0) + assert.Equal(t, startedIdx+1, uptimeIdx, "Uptime should follow Started") + }) + + t.Run("uptime line is omitted when empty", func(t *testing.T) { + report := statusReport{Running: true, PID: 123} + for _, line := range statusTextLines(report) { + assert.NotContains(t, line, "Uptime:") + } + }) +} + +func TestFormatUptime(t *testing.T) { + tests := []struct { + name string + in time.Duration + want string + }{ + {"negative clamps to zero", -5 * time.Second, "0s"}, + {"seconds only", 45 * time.Second, "45s"}, + {"minutes and seconds", 3*time.Minute + 12*time.Second, "3m12s"}, + {"pads seconds", 3*time.Minute + 2*time.Second, "3m02s"}, + {"hours and minutes drop seconds", time.Hour + 4*time.Minute + 9*time.Second, "1h04m"}, + {"days and hours", 2*24*time.Hour + 3*time.Hour + 30*time.Minute, "2d03h"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, formatUptime(tt.in)) + }) + } } func TestFilterStatusReport(t *testing.T) {