Skip to content

feat(api): read endpoints for the native stats dashboard - #125

Merged
tarakanof merged 11 commits into
overhaul/ui-ng-2026-09from
feat/110-dashboard-api
Sep 26, 2026
Merged

tarakanof merged 11 commits into
overhaul/ui-ng-2026-09from
feat/110-dashboard-api

Conversation

@tarakanof

Copy link
Copy Markdown
Owner

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 / period windows with total, by_tool, by_source (active_sec, sessions, attention), plus a zero-filled daily per-tool series. The rollup lives in internal/pomodoro/activity.go and 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's wifiRssi, heap, uptime, wifi.connects and 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:

  • Workhours fix: empty days now emit work_start/work_end: null instead of 0001-01-01T00:00:00Z. The HTML dashboard's fmtClock already handles null.
  • Activity recording: a session's state change now bypasses the 2-min heartbeat throttle. Without it, a short waiting prompt left no row and attention read 0.
  • EmberKit: DashboardModels.swift and DashboardService.swift (usage, activity, weather, clock health, work hours), with decode tests on fixtures captured from the Go handlers. No UI.
  • Docs: AGENTS endpoint list, the ARCHITECTURE "Dashboard read API" section, RUNBOOK usage verify.

Wire conventions (for Swift Charts / JSONDecoder .iso8601)

  • RFC 3339 timestamps with whole seconds. .iso8601 rejects fractional seconds.
  • null instead of zero sentinels.
  • Series are arrays of {time|date, value} points.
  • Units in every key: _sec, _percent, _c, _dbm, _bytes, _ugm3.

Tests

  • go vet ./...: clean. go test ./... -race: all pass. gofmt -l only flags cmd/ember/device_display_test.go, which was already unformatted on the base branch (untouched here).
  • New Go tests cover:
    • no-token access
    • ISO whole-second timestamps
    • union and attention math
    • zero-filled daily series
    • 404 without a store
    • weather before and after the first fetch, with no location leak
    • clock health telemetry, the probe cache (3 requests hit the clock once), and an unreachable clock
    • the empty-day null work span
    • a state change recorded inside the throttle
  • swift test --package-path macos: 225 tests pass, 7 of them new.
  • The xcodebuild Debug app build compiles. Only the Developer-ID sign script fails (no identity on this Mac), and the new files add no warnings.

Notes / follow-ups

  • EmberKit.DeviceStats reads RSSI from wifi.rssi, but NG 1.1.2 reports it top-level as wifiRssi. That was checked with one read-only GET to the live clock. Out of scope here, so it's not fixed.
  • Pure additions except the workhours null change. Any client that parsed work_start as a non-optional date must now accept null.

@tarakanof tarakanof left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. 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 by source_color. daily here is per tool, and it has no source_color. The spec's Day { bySource: [SourceTotal{source, tool, activeMin, sessions, attention}] } has no equivalent.
      • Fix: add a daily_by_source series (or a group=source|tool param) and a colour per source.
    • /v1/clock/health: card 1 needs currentApp, and NG returns currentApp in the same /api/v1/device response. Card 11 needs latest_firmware (the "Update available" badge) and a 24 h publish success rate. This PR gives counters since server start. matrixPower and lowBattery also come free in the same payload.
    • /v1/weather/state: nested current/air/sun instead of the flat WeatherState. No condition_code and no location_name. Dropping the location name is defensible, but the spec needs to say so.
    • /v1/usage: models is 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 in EmberKit/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.
  2. Sunrise/sunset leak the home location (dashboard_http.go, handleWeatherState). GET /v1/weather/config is behind the token because lat/lon are treated as secret. This open endpoint returns sunTimes(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.

  3. A cancelled request poisons the clock probe cache for 30 s (probeClockHealth). The probe runs on the first caller's r.Context(). If that viewer disconnects (menu closed, window closed, 15 s poll cancelled), the probe fails and reachable:false is cached for everyone for 30 s. I reproduced it with a scratch test: a cancelled ctx followed by a background ctx gave reachable=false both times and 0 clock hits. Fix: context.WithoutCancel(ctx) before the WithTimeout. Or cache only successes, with a shorter negative TTL.

  4. Waiting time counts as active agent time (internal/pomodoro/activity.go). Spans are built from every recorded row, and activeWorkState includes waiting. The Claude producer's heartbeat re-POSTs live markers, including waiting ones, every 10 s for up to HeartbeatTTLHours = 6. So an unanswered permission prompt adds up to 6 h of active_sec to "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_sec from running (and maybe error) rows only, and optionally add a separate waiting_sec.
    • Add a test: running 10 min → waiting 60 min → running 10 min should give 20 min.
  5. 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 single MaxOpenConns(1) connection, synchronously in the /v1/status handler. 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=90 reads and walks every row for 90 days while holding that same connection. That's fine on a LAN, but worth a comment.
  6. 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 -update flag) 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 cover daily with more than one tool or absent omitempty keys (source, reset_label).

Nits

  1. last_button_at goes 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.
  2. Spec §5.2 asks the WorkHours decoder to map 0001-01-01 to nil, so a new app still works against an older server. The "Needs server 0.28" states assume version skew. WorkHours.Day decodes the sentinel as a real date. Add a custom init(from:) or a date check.
  3. activitySpans makes 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 in span_gap_sec's comment.
  4. hourlyBase assumes the first hourly value is the server's current hour. With Open-Meteo timezone=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.
  5. handleActivitySummary returns 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 logicalDayStart consistently with dayKey, and AddDate keeps wall-clock time across DST.
  • The GET/POST split on /v1/usage keeps 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.

tarakanof added a commit that referenced this pull request Sep 26, 2026
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
@tarakanof
tarakanof force-pushed the feat/110-dashboard-api branch from 3540c10 to 0bca502 Compare September 26, 2026 08:50
@tarakanof

Copy link
Copy Markdown
Owner Author

Rebased onto overhaul/ui-ng-2026-09 (#118 #121 #122 #123 #126). Fixes review items 1-8 and 10-11.

  1. Shapes now follow spec §5.2, and §5.2 has been updated to match what shipped (scratchpad and Obsidian copies).
    • Activity adds daily_by_source with source_color.
    • Clock health adds current_app, matrix_power, low_battery, 24h counts, and latest_firmware/update_available. The release lookup hits GitHub at most every 6h, retries 30 min after a failure, and falls back to null.
    • Weather adds condition_code and location_name (the user's label only, never coordinates).
    • Usage models is now a map.
    • Swift models moved to EmberKit/Models/, with one service per feed in EmberKit/Services/.
  2. Sun times are rounded to 5 minutes.
  3. The clock probe uses context.WithoutCancel.
  4. Active time counts running and error rows only; waiting rows no longer count.
  5. The throttle bypass now applies only to transitions into waiting, with a 10s floor. days is capped at 90, and activity summary and clock health are rate-limited per IP.
  6. TestDashboardGolden writes cmd/ember/testdata/dashboard/*.json from the real builders (fixed now and zone). The Swift tests decode those files and assert concrete values.
  7. last_button_at is removed.
  8. The WorkHours decoder maps 0001-01-01 to nil.
  9. Hourly points use the provider's own series start (timeformat=unixtime / met.no time). If the start is unknown, the series is empty.
  10. SQLite errors are logged, and the endpoint returns a generic internal error.

Tests: go vet, go test ./... -race and swift test (240) all pass. The app build compiles; only the Developer-ID sign script fails, as before. The workhours fixture is still inline in Swift, because its times depend on the machine's time zone.

@tarakanof tarakanof left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_source with source_color
    • health has current_app, matrix_power, low_battery, ok_24h/fail_24h/success_ratio_24h (from a 24-bucket hourly ring) and latest_firmware/update_available
    • weather has condition_code and location_name
    • usage models is a map
    • Swift has one file per feed under Models/ and one service per feed under Services/
  • 2. Sun times go through Round(5m), and a test covers it.
  • 3. The probe uses context.WithoutCancel, and TestClockHealthProbeIgnoresCallerCancellation covers it.
  • 4. workingState counts 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. days is capped at 90.
  • 6. The goldens compare by default and are rewritten only with -update. A drift fails go test with a hint. The Swift tests read the same files by #filePath.
  • 7. last_button_at is removed.
  • 8. The WorkHours decoder maps the 0001 sentinel to nil.
  • 10. The hourly series is anchored on the provider's own stamp (timeformat=unixtime; met.no uses timeseries[0].time). The Open-Meteo current block has no time field, 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_sec comment.

#123 regression check: clean

  • The retention sweep and prune are unchanged. Only last became last.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 failed at Error, which is right.

Findings

  1. 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_ADVERTISE pattern: default on, 0/false/no/off disables. Leave app.firmware.url empty when it's off, which already makes latest_firmware and update_available null.
    • discovery.AdvertiseEnabled is the parser, but it's named for mDNS, so lift it into a generic helper such as envEnabled rather than calling it from here.
    • Document the variable in RUNBOOK next to EMBER_MDNS_ADVERTISE and in the AGENTS.md env list.
    • The rest of the lookup is sound:
      • It fails soft: an error keeps the last value, ok stays 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/latest excludes prereleases, and the live tag v1.1.2 parses.
  2. should-fix (minor): the GitHub lookup sits on the request path, serially after the clock probe, and both hold locks. buildClockHealth runs the probe first (up to 3 s under clockProbe.mu), then the lookup (up to 4 s under firmware.mu).

    • With a lossy or unreachable clock and a black-holed or slow network, one request takes about 7 s. EmberKit's timeoutIntervalForRequest is 5 s, so that poll fails once per 30-minute retry window.
    • Every other health request queues on firmware.mu for the same 4 s.
    • Fix: stale-while-revalidate. latestVersion returns the cached value right away, and if the entry is due it kicks one background go f.refresh(), guarded by an inFlight flag. That also takes GitHub off the request path entirely.
  3. 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.

  4. 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_color is omitempty (the key is absent), while activitySourceDay.source_color is an explicit null. Pick one. Swift's decodeIfPresent handles both, but the contract should be uniform.
  5. nit (golden flow): The flow works: compare by default, -update to rewrite, a fixed now and 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 local swift test. That gap predates this PR; a macOS swift test job would close it.

  6. OK, no change needed (rate limit). /v1/activity/summary and /v1/clock/health share 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/status every 2–10 s through the same bucket, usage stays far under the refill rate, so there's no starvation. RateLimitBackoff handles 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant