Repository navigation
feat(api): read endpoints for the native stats dashboard - #125
Conversation
tarakanof
left a comment
There was a problem hiding this comment.
Review: #125 (closes #110)
Verdict: fix first. The Go side is solid: go vet is clean, go test ./... -race passes and swift test passes (225 tests). The main problem is the wire shapes. They diverge from design spec §5.2 and from the dashboard cards in §2.3, and #119 is meant to consume them. Either the server or the spec has to change before #119 starts. There is also one location leak and one cache-poisoning bug.
Should-fix
-
The shapes don't match spec §5.2 / §2.3, and #119 is built on them. The spec says "the client is written first against the shapes in §5.2; the server follows them". What this PR ships:
/v1/activity/summary: card 10 ("Agent time") stacks bars per day by source, coloured bysource_color.dailyhere is per tool, and it has nosource_color. The spec'sDay { bySource: [SourceTotal{source, tool, activeMin, sessions, attention}] }has no equivalent.- Fix: add a
daily_by_sourceseries (or agroup=source|toolparam) and a colour per source.
- Fix: add a
/v1/clock/health: card 1 needscurrentApp, and NG returnscurrentAppin the same/api/v1/deviceresponse. Card 11 needslatest_firmware(the "Update available" badge) and a 24 h publish success rate. This PR gives counters since server start.matrixPowerandlowBatteryalso come free in the same payload./v1/weather/state: nestedcurrent/air/suninstead of the flatWeatherState. Nocondition_codeand nolocation_name. Dropping the location name is defensible, but the spec needs to say so./v1/usage:modelsis an array. The spec has[String: Window]. The array is better; the spec should change.- The Swift files go in
EmberKit/DashboardModels.swift+DashboardService.swift. §5.2 puts them inEmberKit/Models/with one service per file (UsageService.snapshot(),HealthService,ActivityService,WeatherService.state()). #119 freezes file ownership, so decide now whether #119 moves them. - Suggestion: keep the better server shapes (nested, unit-suffixed), add the missing fields above, and update §5.2 in the same PR so #119 has a single contract.
-
Sunrise/sunset leak the home location (
dashboard_http.go,handleWeatherState).GET /v1/weather/configis behind the token because lat/lon are treated as secret. This open endpoint returnssunTimes(lat, lon, now)to the second. The algorithm is deterministic, so inverting it is easy: solar noon to 1 s pins longitude to about 0.004° (~300 m), and day length narrows latitude. Card 12 only shows HH:mm, so round both times to the minute or to 5 minutes. That leaves city-level precision. -
A cancelled request poisons the clock probe cache for 30 s (
probeClockHealth). The probe runs on the first caller'sr.Context(). If that viewer disconnects (menu closed, window closed, 15 s poll cancelled), the probe fails andreachable:falseis cached for everyone for 30 s. I reproduced it with a scratch test: a cancelled ctx followed by a background ctx gavereachable=falseboth times and 0 clock hits. Fix:context.WithoutCancel(ctx)before theWithTimeout. Or cache only successes, with a shorter negative TTL. -
Waiting time counts as active agent time (
internal/pomodoro/activity.go). Spans are built from every recorded row, andactiveWorkStateincludeswaiting. The Claude producer's heartbeat re-POSTs live markers, includingwaitingones, every 10 s for up toHeartbeatTTLHours = 6. So an unanswered permission prompt adds up to 6 h ofactive_secto "Agent time". This was acceptable for the work-hours overlay ("I was at work"), but a metric called agent active time shouldn't include it.- Fix: build
active_secfromrunning(and maybeerror) rows only, and optionally add a separatewaiting_sec. - Add a test: running 10 min → waiting 60 min → running 10 min should give 20 min.
- Fix: build
-
The state-change throttle bypass has no floor (
recordActivityHeartbeat). Normal running↔waiting flips are human-paced and fine. But a producer bug, or two producers writing the same session key with different states (every 2–10 s), now produces one INSERT per POST, on the singleMaxOpenConns(1)connection, synchronously in the/v1/statushandler. The old throttle capped this at 1 row per 2 min.- Fix: bypass only on a transition into
waiting(the attention signal the change exists for), or keep a small floor such as 10 s per session. - Related: an unauthenticated
?days=90reads and walks every row for 90 days while holding that same connection. That's fine on a LAN, but worth a comment.
- Fix: bypass only on a transition into
-
Nothing ties the Swift fixtures to the Go output. They are hand-pasted strings described as "captured from the Go handlers". If a Go field is renamed, the Swift tests still pass.
- Fix: have the Go tests write golden files (
testdata/dashboard/*.json, with an-updateflag) and point the Swift tests at them. At minimum, add a Go test that diffs the key set against those files. - The Swift tests also only check
successRatio != nil. They don't check the value, and they don't coverdailywith more than one tool or absentomitemptykeys (source,reset_label).
- Fix: have the Go tests write golden files (
Nits
last_button_atgoes out unauthenticated.GET /v1/device/buttons, which reports the same data, requires the token, and neither the spec nor the issue asks for it here. It leaks physical presence at the clock. Drop it or put it behind the token.- Spec §5.2 asks the
WorkHoursdecoder to map0001-01-01to nil, so a new app still works against an older server. The "Needs server 0.28" states assume version skew.WorkHours.Daydecodes the sentinel as a real date. Add a custominit(from:)or a date check. activitySpansmakes an isolated beat a zero-width span and cuts each span at its last beat. With a 2 min throttle, a short session reads 0 s, and every span under-counts by up to one throttle interval. The same span logic is split at the day-start boundary. Both effects are small; document them inspan_gap_sec's comment.hourlyBaseassumes the first hourly value is the server's current hour. With Open-Meteotimezone=auto, a location with a :30 offset, or met.no's cached series starting an hour earlier, shifts the whole series by up to an hour.handleActivitySummaryreturns the raw SQLite error text on a 500 over an open endpoint. Log it and return a generic message.
On wifi.rssi vs wifiRssi (EmberKit DeviceStats)
Don't fix it here. Nothing in macos/Ember or EmberKit reads WifiInfo.rssi, so it's dead, not wrong-on-screen. Spec §5.2 already says "WifiInfo fixed to NG's shape (audit B10) as part of ClockHealth; the old struct is left alone". ClockHealth.device.wifiRssiDbm from this PR covers it. Delete or fix WifiInfo when #112 splits DeviceTab. A one-line CodingKeys fix here would only add churn to a struct that is about to be superseded.
Checked and fine
- No token, IP, SSID, UID, hostname, session key or activity/prompt text on any of the four endpoints.
- Every timestamp is whole-second (DB unix seconds,
wireTime,time.Unix). - Day windows use
logicalDayStartconsistently withdayKey, andAddDatekeeps wall-clock time across DST. - The GET/POST split on
/v1/usagekeeps the POST behind the token. - The workhours null change is covered and the HTML page already handles null.
- The probe mutex single-flights correctly.
- Docs are updated.
Review of #125 against the app UX spec. The Agent time card stacks days by source in the source colour, the Clock card needs the current app, and Clock health needs the latest firmware and a 24h success rate. None of that was served, and each gap is added here: - activity summary gains daily_by_source with source_color. The colour is remembered from status posts. - clock health gains current_app, matrix_power, low_battery, 24h publish counts, and latest_firmware plus update_available. The release lookup hits GitHub at most every 6h and fails soft to null. - weather state gains condition_code and location_name. - usage models become a map keyed by model name. It also tightens what an open endpoint gives away or gets wrong: - sunrise and sunset are rounded to 5 min so they can't pin the coordinates. - last_button_at is dropped. - the clock probe runs detached from the caller's cancellation, so a disconnecting viewer can't cache "unreachable" for everyone. - storage errors are logged, not returned. - hourly points use the provider's own series start instead of assuming the current hour. - the two endpoints that do I/O are rate-limited. Handlers now wrap build* methods that take a fixed now, so testdata/dashboard/*.json goldens come from the real code and EmberKit decodes the same files. Refs #110
3540c10 to
0bca502
Compare
|
Rebased onto overhaul/ui-ng-2026-09 (#118 #121 #122 #123 #126). Fixes review items 1-8 and 10-11.
Tests: |
tarakanof
left a comment
There was a problem hiding this comment.
Re-review: #125 after the fix round
Verdict: merge after one small fix (finding 1). Finding 2 can go in the same push or a follow-up. I re-ran go vet, go test ./... -race and swift test (240 tests) on 0bca502, and all pass. CI (Go) passes. The branch contains the current overhaul/ui-ng-2026-09.
First-review findings: all verified fixed
- 1 (shapes). These now match §5.2:
- activity has
daily_by_sourcewithsource_color - health has
current_app,matrix_power,low_battery,ok_24h/fail_24h/success_ratio_24h(from a 24-bucket hourly ring) andlatest_firmware/update_available - weather has
condition_codeandlocation_name - usage
modelsis a map - Swift has one file per feed under
Models/and one service per feed underServices/
- activity has
- 2. Sun times go through
Round(5m), and a test covers it. - 3. The probe uses
context.WithoutCancel, andTestClockHealthProbeIgnoresCallerCancellationcovers it. - 4.
workingStatecounts only running and error rows, and a test covers it. - 5. The throttle bypass now applies only to transitions into waiting, with a 10 s floor. The step test pins the floor, the exit and the repeated-waiting case.
daysis capped at 90. - 6. The goldens compare by default and are rewritten only with
-update. A drift failsgo testwith a hint. The Swift tests read the same files by#filePath. - 7.
last_button_atis removed. - 8. The WorkHours decoder maps the
0001sentinel to nil. - 10. The hourly series is anchored on the provider's own stamp (
timeformat=unixtime; met.no usestimeseries[0].time). The Open-Meteocurrentblock has notimefield, so the unixtime switch can't break its decode. - 11. Storage errors are logged, the response is a generic
internal error, and a test covers it. - 9 was a doc nit and is addressed in the
span_gap_seccomment.
#123 regression check: clean
- The retention sweep and prune are unchanged. Only
lastbecamelast.at. - The stats cache is untouched.
- The access log still logs 2xx at Debug and ≥400 at Info, so the new 15 s poll stays quiet.
- The only new log line is
activity summary failedat Error, which is right.
Findings
-
should-fix: gate the GitHub release lookup behind
EMBER_FIRMWARE_CHECK. I recommend yes. It is the only outbound call the server makes that the user never configured. Weather and ICS are both opt-in by config. A self-hosted home server phoning GitHub by default deserves an off switch.- Follow the
EMBER_MDNS_ADVERTISEpattern: default on,0/false/no/offdisables. Leaveapp.firmware.urlempty when it's off, which already makeslatest_firmwareandupdate_availablenull. discovery.AdvertiseEnabledis the parser, but it's named for mDNS, so lift it into a generic helper such asenvEnabledrather than calling it from here.- Document the variable in RUNBOOK next to
EMBER_MDNS_ADVERTISEand in the AGENTS.md env list. - The rest of the lookup is sound:
- It fails soft: an error keeps the last value,
okstays false and it retries after 30 min. - It has a 4 s timeout and ignores the caller's cancellation.
- It caches for 6 h and single-flights under a mutex.
- The body is capped at 1 MiB.
- The tag is validated through
parseVersion. - It never runs when nobody polls health.
- Nothing leaks: the request carries no auth header, only a UA, and uses its own client. The unauthenticated GitHub limit (60/h) is far above 4 lookups a day.
releases/latestexcludes prereleases, and the live tagv1.1.2parses.
- It fails soft: an error keeps the last value,
- Follow the
-
should-fix (minor): the GitHub lookup sits on the request path, serially after the clock probe, and both hold locks.
buildClockHealthruns the probe first (up to 3 s underclockProbe.mu), then the lookup (up to 4 s underfirmware.mu).- With a lossy or unreachable clock and a black-holed or slow network, one request takes about 7 s. EmberKit's
timeoutIntervalForRequestis 5 s, so that poll fails once per 30-minute retry window. - Every other health request queues on
firmware.mufor the same 4 s. - Fix: stale-while-revalidate.
latestVersionreturns the cached value right away, and if the entry is due it kicks one backgroundgo f.refresh(), guarded by aninFlightflag. That also takes GitHub off the request path entirely.
- With a lossy or unreachable clock and a black-holed or slow network, one request takes about 7 s. EmberKit's
-
nit: A failed release lookup is completely silent. Log it at Debug, or at Warn on the first failure after a success, so "why no update badge" can be answered from the logs.
-
nit (source_color memo): The memo is fine for its purpose. It is in memory only, as documented, and refills as producers post. Its keys come only from the authed
/v1/status, and the colour is hex-validated there, so the map can't be grown or poisoned from the open side. Two small points:- After a server restart, historical bars are null-coloured until each source posts again. The client needs a fallback palette; say so in §5.2 if it doesn't already.
activityTotalsOut.source_colorisomitempty(the key is absent), whileactivitySourceDay.source_coloris an explicitnull. Pick one. Swift'sdecodeIfPresenthandles both, but the contract should be uniform.
-
nit (golden flow): The flow works: compare by default,
-updateto rewrite, a fixednowand zone, and the decode tests read those files. But CI runs only the Go job. A Go-side shape change that breaks Swift decoding is caught only by a localswift test. That gap predates this PR; a macOSswift testjob would close it. -
OK, no change needed (rate limit).
/v1/activity/summaryand/v1/clock/healthshare the per-IP token bucket: burst 60, refill 5/s. The app's polling for them is 1/15 s + 1/300 s ≈ 0.07 req/s. Even with the same Mac's producers posting/v1/statusevery 2–10 s through the same bucket, usage stays far under the refill rate, so there's no starvation.RateLimitBackoffhandles a 429 anyway.
GET /v1/pomodoro/workhours serialised empty days with Go's zero time (0001-01-01T00:00:00Z) for work_start/work_end. Every client had to special-case that sentinel, and a chart that forgot would plot a bar two thousand years long. A nil pointer marshals to null, which Swift's Optional<Date> and the HTML dashboard's fmtClock already treat as "no span".
The activity table has fed the work-hours overlay since it landed, but nothing breaks it down by who was working. The native dashboard (#110) wants agent time per tool and per source, session counts, and how often an agent stopped to ask for attention. Active time uses the same span reconstruction and logical-day bucketing as work hours, then unions spans within a group so two concurrent sessions of one tool count wall-clock time once.
Activity rows are throttled to one per session per two minutes. A session that went running -> waiting -> running inside that window left no waiting row, so the attention count read zero for exactly the short prompts it exists to show. A transition into waiting now writes a row once 10s have passed since the session's last one. The floor keeps a flapping producer, or two producers sharing a session key, from writing a row per POST on the store's single connection.
The macOS dashboard needs data the server holds but never serves: the usage snapshot producers POST, agent activity per tool and source, the cached weather observation, and clock link health. Add GET /v1/usage, /v1/activity/summary, /v1/weather/state and /v1/clock/health, unauthenticated like /state. The shapes are built for Swift Charts: RFC 3339 timestamps with whole seconds (JSONDecoder's .iso8601 rejects fractions), null instead of zero sentinels, series as arrays of points, units in every key. Nothing secret goes out: no coordinates, clock IP, SSID or UID. Clock health caches its GET /api/v1/device for 30s so an open endpoint can't turn dashboard polling into traffic on the clock's lossy Wi-Fi. It reads wifiRssi, heap and uptime from the device and the publish counters from memory, so nobody has to scrape /metrics. Refs #110
The native dashboard rebuild needs typed access to the new read endpoints before any view can be written. Add Decodable models and a DashboardService for usage, activity summary, weather state, clock health and work hours, with Identifiable points ready for Swift Charts. The decode tests use fixtures captured from the Go handlers, so they pin the real wire contract, including the null work span on an empty day and the all-null telemetry of an unreachable clock. No UI yet.
Agents and the dashboard rebuild need to find the new open endpoints, their wire conventions, and the throttle change behind the attention count without reading the handlers.
Producers re-post a waiting marker every 10s for up to six hours while a permission prompt sits unanswered, so the span logic turned an ignored prompt into hours of "active" agent time. Waiting rows still feed the session and attention counts; they no longer add time. Also adds a per-day rollup for the dashboard's daily series. Refs #110
Review of #125 against the app UX spec. The Agent time card stacks days by source in the source colour, the Clock card needs the current app, and Clock health needs the latest firmware and a 24h success rate. None of that was served, and each gap is added here: - activity summary gains daily_by_source with source_color. The colour is remembered from status posts. - clock health gains current_app, matrix_power, low_battery, 24h publish counts, and latest_firmware plus update_available. The release lookup hits GitHub at most every 6h and fails soft to null. - weather state gains condition_code and location_name. - usage models become a map keyed by model name. It also tightens what an open endpoint gives away or gets wrong: - sunrise and sunset are rounded to 5 min so they can't pin the coordinates. - last_button_at is dropped. - the clock probe runs detached from the caller's cancellation, so a disconnecting viewer can't cache "unreachable" for everyone. - storage errors are logged, not returned. - hourly points use the provider's own series start instead of assuming the current hour. - the two endpoints that do I/O are rate-limited. Handlers now wrap build* methods that take a fixed now, so testdata/dashboard/*.json goldens come from the real code and EmberKit decodes the same files. Refs #110
… goldens Spec 5.2 puts wire models in EmberKit/Models/ and gives each feed its own service (UsageService.snapshot, WeatherService.state, HealthService, ActivityService, StatsService). Do that now so #119 can build on the structure without re-shuffling files. The models follow the reworked server shapes. The decode tests now read cmd/ember/testdata/dashboard, which the Go golden test generates from the real builders. A field renamed on either side now breaks a suite; with the hand-pasted strings it passed quietly. WorkHours maps the pre-0.28 zero-time sentinel to nil, so a new app still reads an old server correctly. Refs #110
The endpoint reference now matches the reworked shapes and states the open-endpoint trade-offs a reader needs before changing them: rounded sun times, the waiting exclusion, the GitHub release lookup, and how to regenerate the shared goldens. Refs #110
Second review of #125. - The GitHub release lookup ran on the request path, so a slow api.github.com stalled /v1/clock/health for up to 4s. It now runs in a single in-flight background goroutine; the endpoint serves the cached answer and never waits. - A failed lookup is logged at Warn, at most once per 30-min retry window. - EMBER_FIRMWARE_CHECK=0 keeps the server off the internet entirely: the firmware fields read null and no request is made. Its parsing is the generic envEnabled helper, lifted out of discovery.AdvertiseEnabled, which EMBER_MDNS_ADVERTISE now uses too. - by_source rows send source_color as explicit null when unknown, the same convention as daily_by_source; tool rows no longer carry the key. The golden file is regenerated. Refs #110
0bca502 to
e3a3ca5
Compare
Closes #110
What
Four open (no token) read endpoints for the macOS dashboard, in
cmd/ember/dashboard_http.go(main.go only registers routes + one cache field):GET /v1/usage: latest usage snapshot per tool (5h/7d, per-model sorted by name,stale). The POST stays authed and still routes to the write mux.GET /v1/activity/summary?days=7(1..90):today/periodwindows withtotal,by_tool,by_source(active_sec,sessions,attention), plus a zero-filleddailyper-tool series. The rollup lives ininternal/pomodoro/activity.goand reuses the work-hours span logic. Concurrent sessions of one tool count wall-clock time once.GET /v1/weather/state: the cached observation, air quality and sunrise/sunset. No provider call. Location not echoed.GET /v1/clock/health: publish ok/fail/retries, success ratio and last publish, plus the clock'swifiRssi, heap, uptime,wifi.connectsand reset reason. The device probe is cached for 30s so polling can't add traffic on the lossy clock link. IP, SSID and UID are dropped.Also:
work_start/work_end: nullinstead of0001-01-01T00:00:00Z. The HTML dashboard'sfmtClockalready handles null.attentionread 0.DashboardModels.swiftandDashboardService.swift(usage, activity, weather, clock health, work hours), with decode tests on fixtures captured from the Go handlers. No UI.Wire conventions (for Swift Charts / JSONDecoder .iso8601)
.iso8601rejects fractional seconds.nullinstead of zero sentinels.{time|date, value}points._sec,_percent,_c,_dbm,_bytes,_ugm3.Tests
go vet ./...: clean.go test ./... -race: all pass.gofmt -lonly flagscmd/ember/device_display_test.go, which was already unformatted on the base branch (untouched here).swift test --package-path macos: 225 tests pass, 7 of them new.Notes / follow-ups
EmberKit.DeviceStatsreads RSSI fromwifi.rssi, but NG 1.1.2 reports it top-level aswifiRssi. That was checked with one read-only GET to the live clock. Out of scope here, so it's not fixed.work_startas a non-optional date must now accept null.