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) {