Skip to content

Commit 529bb89

Browse files
implement GET /api/v1/admin/status endpoint for system health monitoring
1 parent 29db783 commit 529bb89

5 files changed

Lines changed: 178 additions & 0 deletions

File tree

handlers.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,27 @@ func buildFeed(vehicles []*VehicleState) *gtfs.FeedMessage {
147147
return feed
148148
}
149149

150+
type adminStatusResponse struct {
151+
Status string `json:"status"`
152+
UptimeSeconds int64 `json:"uptime_seconds"`
153+
ActiveVehicles int `json:"active_vehicles"`
154+
TotalVehiclesTracked int `json:"total_vehicles_tracked"`
155+
LastUpdate *time.Time `json:"last_update,omitempty"`
156+
}
157+
158+
func handleAdminStatus(tracker *Tracker, startTime time.Time) http.HandlerFunc {
159+
return func(w http.ResponseWriter, r *http.Request) {
160+
ts := tracker.Status()
161+
writeJSON(w, http.StatusOK, adminStatusResponse{
162+
Status: "ok",
163+
UptimeSeconds: int64(time.Since(startTime).Seconds()),
164+
ActiveVehicles: ts.ActiveVehicles,
165+
TotalVehiclesTracked: ts.TotalVehiclesTracked,
166+
LastUpdate: ts.LastUpdate,
167+
})
168+
}
169+
}
170+
150171
func writeJSON(w http.ResponseWriter, status int, v any) {
151172
w.Header().Set("Content-Type", "application/json")
152173
w.WriteHeader(status)

handlers_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,87 @@ func TestHandlePostLocation_Validation(t *testing.T) {
213213
}
214214
}
215215

216+
func TestHandleAdminStatus_Empty(t *testing.T) {
217+
tracker := NewTracker(5 * time.Minute)
218+
defer tracker.Stop()
219+
220+
handler := handleAdminStatus(tracker, time.Now())
221+
req := httptest.NewRequest("GET", "/api/v1/admin/status", nil)
222+
w := httptest.NewRecorder()
223+
handler(w, req)
224+
225+
assert.Equal(t, http.StatusOK, w.Code)
226+
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
227+
228+
var resp adminStatusResponse
229+
err := json.NewDecoder(w.Body).Decode(&resp)
230+
require.NoError(t, err)
231+
232+
assert.Equal(t, "ok", resp.Status)
233+
assert.Equal(t, 0, resp.ActiveVehicles)
234+
assert.Equal(t, 0, resp.TotalVehiclesTracked)
235+
assert.Nil(t, resp.LastUpdate)
236+
}
237+
238+
func TestHandleAdminStatus_WithVehicles(t *testing.T) {
239+
tracker := NewTracker(5 * time.Minute)
240+
defer tracker.Stop()
241+
242+
tracker.Update(&LocationReport{VehicleID: "bus-1", Latitude: 1, Longitude: 2, Timestamp: 100})
243+
tracker.Update(&LocationReport{VehicleID: "bus-2", Latitude: 3, Longitude: 4, Timestamp: 200})
244+
245+
handler := handleAdminStatus(tracker, time.Now())
246+
req := httptest.NewRequest("GET", "/api/v1/admin/status", nil)
247+
w := httptest.NewRecorder()
248+
handler(w, req)
249+
250+
assert.Equal(t, http.StatusOK, w.Code)
251+
252+
var resp adminStatusResponse
253+
err := json.NewDecoder(w.Body).Decode(&resp)
254+
require.NoError(t, err)
255+
256+
assert.Equal(t, "ok", resp.Status)
257+
assert.Equal(t, 2, resp.ActiveVehicles)
258+
assert.Equal(t, 2, resp.TotalVehiclesTracked)
259+
require.NotNil(t, resp.LastUpdate)
260+
assert.False(t, resp.LastUpdate.IsZero())
261+
}
262+
263+
func TestHandleAdminStatus_Uptime(t *testing.T) {
264+
tracker := NewTracker(5 * time.Minute)
265+
defer tracker.Stop()
266+
267+
startTime := time.Now().Add(-10 * time.Second)
268+
handler := handleAdminStatus(tracker, startTime)
269+
req := httptest.NewRequest("GET", "/api/v1/admin/status", nil)
270+
w := httptest.NewRecorder()
271+
handler(w, req)
272+
273+
var resp adminStatusResponse
274+
err := json.NewDecoder(w.Body).Decode(&resp)
275+
require.NoError(t, err)
276+
277+
assert.GreaterOrEqual(t, resp.UptimeSeconds, int64(10))
278+
}
279+
280+
func TestHandleAdminStatus_ZeroVehiclesNotNull(t *testing.T) {
281+
tracker := NewTracker(5 * time.Minute)
282+
defer tracker.Stop()
283+
284+
handler := handleAdminStatus(tracker, time.Now())
285+
req := httptest.NewRequest("GET", "/api/v1/admin/status", nil)
286+
w := httptest.NewRecorder()
287+
handler(w, req)
288+
289+
var raw map[string]any
290+
err := json.NewDecoder(w.Body).Decode(&raw)
291+
require.NoError(t, err)
292+
293+
assert.Equal(t, float64(0), raw["active_vehicles"])
294+
assert.Equal(t, float64(0), raw["total_vehicles_tracked"])
295+
}
296+
216297
type mockStore struct {
217298
err error
218299
saved bool

main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,12 @@ func main() {
4747
log.Printf("seeded tracker with %d active vehicles", len(recentLocations))
4848
}
4949

50+
startTime := time.Now()
51+
5052
mux := http.NewServeMux()
5153
mux.HandleFunc("POST /api/v1/locations", handlePostLocation(store, tracker))
5254
mux.HandleFunc("GET /gtfs-rt/vehicle-positions", handleGetFeed(tracker))
55+
mux.HandleFunc("GET /api/v1/admin/status", handleAdminStatus(tracker, startTime))
5356
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
5457
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
5558
})

tracker.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,35 @@ func (t *Tracker) ActiveVehicles() []*VehicleState {
9191
return active
9292
}
9393

94+
// TrackerStatus holds aggregate statistics about tracked vehicles.
95+
type TrackerStatus struct {
96+
ActiveVehicles int
97+
TotalVehiclesTracked int
98+
LastUpdate *time.Time
99+
}
100+
101+
// Status returns aggregate statistics with a single lock acquisition.
102+
func (t *Tracker) Status() TrackerStatus {
103+
t.mu.RLock()
104+
defer t.mu.RUnlock()
105+
cutoff := time.Now().Add(-t.maxAge)
106+
var s TrackerStatus
107+
s.TotalVehiclesTracked = len(t.vehicles)
108+
var latest time.Time
109+
for _, v := range t.vehicles {
110+
if v.UpdatedAt.After(cutoff) {
111+
s.ActiveVehicles++
112+
}
113+
if v.UpdatedAt.After(latest) {
114+
latest = v.UpdatedAt
115+
}
116+
}
117+
if !latest.IsZero() {
118+
s.LastUpdate = &latest
119+
}
120+
return s
121+
}
122+
94123
// cleanup removes old entries from the tracker to prevent unbounded memory growth.
95124
func (t *Tracker) cleanup() {
96125
t.mu.Lock()

tracker_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,50 @@ func TestTracker_StopIsIdempotent(t *testing.T) {
148148
assert.NotPanics(t, func() { tracker.Stop() })
149149
}
150150

151+
func TestTracker_Status_Empty(t *testing.T) {
152+
tracker := NewTracker(5 * time.Minute)
153+
defer tracker.Stop()
154+
155+
s := tracker.Status()
156+
157+
assert.Equal(t, 0, s.ActiveVehicles)
158+
assert.Equal(t, 0, s.TotalVehiclesTracked)
159+
assert.Nil(t, s.LastUpdate)
160+
}
161+
162+
func TestTracker_Status_WithActiveVehicles(t *testing.T) {
163+
tracker := NewTracker(5 * time.Minute)
164+
defer tracker.Stop()
165+
166+
tracker.Update(&LocationReport{VehicleID: "bus-1", Latitude: 1, Longitude: 2, Timestamp: 100})
167+
tracker.Update(&LocationReport{VehicleID: "bus-2", Latitude: 3, Longitude: 4, Timestamp: 200})
168+
169+
s := tracker.Status()
170+
171+
assert.Equal(t, 2, s.ActiveVehicles)
172+
assert.Equal(t, 2, s.TotalVehiclesTracked)
173+
require.NotNil(t, s.LastUpdate)
174+
assert.False(t, s.LastUpdate.IsZero())
175+
}
176+
177+
func TestTracker_Status_WithStaleVehicles(t *testing.T) {
178+
tracker := NewTracker(5 * time.Minute)
179+
defer tracker.Stop()
180+
181+
tracker.Update(&LocationReport{VehicleID: "old-bus", Latitude: 1, Longitude: 2, Timestamp: 100})
182+
tracker.mu.Lock()
183+
tracker.vehicles["old-bus"].UpdatedAt = time.Now().Add(-10 * time.Minute)
184+
tracker.mu.Unlock()
185+
186+
tracker.Update(&LocationReport{VehicleID: "new-bus", Latitude: 3, Longitude: 4, Timestamp: 200})
187+
188+
s := tracker.Status()
189+
190+
assert.Equal(t, 1, s.ActiveVehicles)
191+
assert.Equal(t, 2, s.TotalVehiclesTracked)
192+
require.NotNil(t, s.LastUpdate)
193+
}
194+
151195
func TestTracker_CleanupPreservesFreshVehicles(t *testing.T) {
152196
maxAge := 50 * time.Millisecond
153197
tracker := NewTracker(maxAge)

0 commit comments

Comments
 (0)