The headline is per-tab workspace state: every workspace tab now owns the entire dashboard view — search, filters, sort, favourites, panel layout, the Devices-panel device filter — isolated and persisted per tab, so switching tabs is a full view restore rather than a shared global state. Alongside it, /cdp graduated from a static page to a live interactive explorer (matching /rest, /ws, /mcp), and the bmc CLI gained a one-shot fleet table plus self-healing ingest.
What changed today
Dashboard (builds 238 → 240)
- Build 238 — audit cycle-333 HIGHs. Cleared the cycle-333 HIGH findings and a live FK-violation storm; fixed audit #070 where the filter
useMemostill depended on the singularfilterDevice/selectedDomain(the pre-multi-select fields) instead of the plural sets. - Build 239 —
/cdpbecomes an explorer. Rebuilt the CDP page as an interactive explorer in the same shape as/rest,/ws, and/mcp— live, not a static doc. - Build 240 — per-tab workspace state (the big one). Each
workspace_tabnow persists the full view intoworkspace_tabs.filter:- Per-tab search / filters / sort / favourites, isolated and restored on tab switch (was global).
- Per-tab panel + layout state; fixed a stale cache that clobbered the cold-load.
- The Devices-panel filter scopes the feed per tab (not just the device list), and all panel counters scope to that per-tab device filter via
countDeviceUuids/feedDeviceRestrictSet. - Per-tab entry loading is scoped to the device set, fixing empty service-group tabs.
- Fixed the per-tab device filter being dropped on reload, plus an Amazon-tab feed refresh loop (the device-scoped fetch was dropping the un-indexed host filter). Exhaustive
@tabse2e coverage added.
cdp-connector / bmc (builds 13 → 18)
- Build 13 —
bmc list/bmc ls: every device + paths + ports + version in one table. - Build 14 —
bmc lsdefaults to a this-machine view; newbmc attach. - Build 15 —
bmc updateauto-refreshes the dashboard's record of the device version. - Build 16 — fixed dashboard version sync.
- Build 17 — ingest self-heals on device-removed (closes #30, #34);
bmc ls --json(#31). - Build 18 —
bmc --clientflag stamps anX-Busymate-Clientmarker on captured traffic.
Supabase (migrations 175 → 177)
- 176 —
github-webhookposts the issue body as a collapsible blockquote. - 177 — render + un-truncate the issue body in the Telegram notification.
Dashboard (build 241) — capture-viewer correctness
A round of device/entry-badge + counter fixes so the feed, the detail panel, and the Devices sidebar agree. All found + verified against a CDP device (Chrome captured via bmc) routing through an external AU proxy.
- CDP entries no longer read "OFF" in the detail panel. The feed row derives its mode badge from
entry.source === "cdp"first, but the entry detail header typedconnectionModeas"VPN" | "PAC" | "OFF"and never inspectedsource— so a CDP entry (noroutedVia, device not PAC-routing) fell through to OFF while the row showed CDP. The sameconnectionModealso drives the device glyph (Chrome icon vs smartphone), so the detail pill diverged in both badge and icon. Fixed by adding thesource === "cdp"case + widening the type; the detail now shows the violet CDP pill + Chrome glyph like the feed. - External-proxy egress flag now shows on CDP entries. The Devices-panel row resolves its egress flag with a final
proxy?.countryfallback to the livedeviceProxiesmap (CountryFlow), but the feed-row + detail-pill derivations stopped atdeviceProxyCountries(the persistedexternal_proxy_countrycolumn). CDP devices never populateexternal_proxy_country(the connector only reportsreal_country_code), so a Chrome routing through an external proxy showed its egress (AU/Sydney, confirmed bycf-ray …-SYD) on the device row but not on its entries. Fixed by threadingdeviceProxiesintoEntryList+EntryDetailand adding the same fallback. - Egress badge renders as flag + ISO code. The live
deviceProxies.countrycan be a full country name ("Australia"), so the shared entry pill now normalizes it throughcountryToIso(...)(likeCountryFlow) and renders flag + 2-letter code (🇦🇺 AU) — matching the real-country + conn-type chip format, not a bare name with a broken flag class. - Devices-panel "All devices" total now agrees with its rows.
deviceScopedTotal(the "All devices" count) is tab-scoped, but each per-device row count was the global lifetime total — so on a tab filtered to one device the aggregate collapsed to that device while sibling rows still showed fleet-wide counts, making "All devices" read smaller than a member row. Per-device counts now follow the tab's device scope (scopedCounts): out-of-scope devices read 0, so All = sum of the visible rows and matches the feed.
Dashboard (build 242) + Supabase (178) — TestFlight + Users admin
Fixes + new admin features in Settings → Users (and the /testflight page).
- Tester delete works on stale rows. A tester revoked/removed on App Store Connect returns 404 ("no resource of type betaTesters"), which the UI treated as a hard error, so a REVOKED row could never be cleared.
delete_testernow coerces ASC 404 → success, and both remove surfaces (UsersClient,TestFlightClient) treat 404 as done. - Internal beta groups are guarded. Assigning an email tester to an INTERNAL group is rejected by ASC ("Tester(s) cannot be assigned") — internal groups only hold ASC team members. The invite picker + add-to-group chips disable internal groups (with a tooltip) until the email is an accepted team member.
- Remove user (new). New
admin-delete-userEdge Function (service-role, admin-gated, self- + last-admin-guarded). Admin-only Remove button → dialog with keep devices + captures (login/profile/tokens deleted; devices unowned) or purge everything (devices + entries also deleted). - Automated internal-tester invites (new). Internal testers must be ASC team members, so the TestFlight tab gains an Internal testing section: Invite as internal tester sends a
userInvitationsrequest with the least-privilege Customer Support role, tracks Invite pending (with Cancel), and unlocks the internal beta-group chips once the person accepts Apple's join-team email. Newtestflight-adminactions:invite_user,list_users,list_user_invitations,cancel_user_invitation. Inherently two-phase — Apple requires the invitee to accept the team invite; no API bypasses that.
Dashboard (build 243) — internal-testing on /testflight
The internal-tester invite automation (242, Settings → Users) now also lives on the standalone /testflight page as an Internal tab: an Invite internal tester form (Customer Support role via userInvitations), a Pending team invites list with Resend + Cancel per row, and a read-only Team members list with roles. Resend has no ASC endpoint, so it cancels + re-creates the invitation (re-sends the email, resets expiry, keeps the original role). The /testflight external-invite form also disables internal beta groups (with a tooltip) to match the Users-tab guard. Dashboard-only — the edge actions shipped in 178.
Dashboard (build 244) — TestFlight redesign + instant Cancel; issue auto-triage Action
- TestFlight Cancel/invite no longer needs a hard refresh. The invite/resend/cancel handlers refetched ASC's eventually-consistent invitations list, so a just-cancelled invite reappeared until ASC propagated. Now the local state updates optimistically (cancel removes the row, invite appends, resend swaps) — on both
/testflightand the Users → TestFlight tab. - TestFlight view redesign. All three
/testflighttabs got a visual pass: avatar initials with stable per-person tint, each ASC role as its own colored badge, "Awaiting acceptance" + expiry on pending invites, count pills, polished empty states, tidier group cards. Pure markup/shadcn — no behavior change. - New: Claude auto-triage GitHub Action (
.github/workflows/claude-issue-triage.yml). Onissues.openedit runsanthropics/claude-code-action@v1in automation mode and reacts immediately — reads the issue, posts one triage comment (summary · component · severity · first step) and applies labels. Triage-only (no code edits / PRs), treats issue text as untrusted. Needs a one-time repo setup to activate: the Claude GitHub app + anANTHROPIC_API_KEYsecret.
Dashboard (build 245) + Docs (139) — pending internal-group adds; docs accuracy pass
- TestFlight: team members not in an internal group are now flagged. The Internal tab reads the testers list to tell which accepted ASC team members aren't in any internal beta group yet, flags them "needs group" (sorted to top + count badge), and offers a per-row Add to <group> button + an Add all. The add reuses
add_to_group(existing betaTester) orinvitestraight into the group (works once they're a team member), clearing the badge optimistically. Closes the invite → accept → add gap. - Docs accuracy pass. MCP tool count corrected to 57 (
mcp.md,supabase.md,how-to/_meta.json); added the missing Push category tomcp.md(the source of the count drift — doc now matches source exactly); added a TestFlight admin section todashboard.md. No broken links / orphan pages found.
Supabase (179) + Docs (140) — last-admin safety net (#37) + public docs (#36)
- #37 [MEDIUM] —
admin-delete-usercan no longer leave zero admins. Audit cycle 350 found a TOCTOU: the function counted admins then deleted non-atomically, so two concurrent removals of the last two admins could both pass → zero admins (unrecoverable). Fixed at the DB level — aBEFORE DELETE OR UPDATEtrigger onpublic.profiles(prevent_last_admin_removal) blocks any op dropping below one admin (covers theauth.userscascade delete AND demote-to-viewer), with a transaction-scoped advisory lock to serialize concurrent removals. Verified on the live DB; EXECUTE revoked from anon/authenticated. - #36 [enhancement] — docs made public + CLI README. A tester couldn't open
docs.busymate.net/architecture/cdp-connector— the docs site was auth-gated (web/docs/middleware.tsredirected to/login). The docs site is now public (middleware no-op; sign-in routes left intact + reversible), plus 5 content pages that 500'd once exposed were fixed.cli/README.mdrewritten into a fullbmcguide (verified against live--help).
Build numbers
- Dashboard: 237 → 238 → 239 → 240 → 241 → 242 → 243 → 244 → 245.
- Docs: 138 → 139 → 140.
- cdp-connector: 12 → 13 → 14 → 15 → 16 → 17 → 18.
- Supabase: 174 → 175 → 176 → 177 → 178 → 179.