Skip to content

Merchant and venue dashboards โ€‹

What is this? โ€‹

Two dashboard surfaces that share one issue. โ€‹

  • The merchant side is what a partner merchant sees about their own offers and foot traffic. It exists because the partnership is what promises them data.
  • The venue and activity side is period activity, wave accept rates and offer delivery in the admin portal, which the plan calls how we watch the experiment while it runs.

What is the current state? โ€‹

  • design-survey.md, 30 items across the admin portal, the merchant portal and the web app, each with its file and line and two or three options.
  • Her five named items are marked HERS and measured. The Dashboard, Ad Network and Offer review each have their own section, since she called all three redesigns.
  • brainstorm.md holds her raw words, including the nav rearrange that is already decided.
  • Evidence: screens/ captures every route off the running stack, harness/ is the Playwright driver that produced them.
  • Nothing is decided. The build follows her rulings on the options.

The two plan rows still need untangling before anyone works from them. โ€‹

  • One issue covers both plan rows. Either split #320 into two, or merge the two rows.
  • This is not academic: a dashboard auto-refresh was already built twice in one day because the two were treated as separate work.
  • Only README.md exists in this project so far.

What was built on 2026-08-27? โ€‹

The admin sidebar rearrange she decided on 2026-08-24, plus a consolidation she added mid-session: every financial surface moved onto one page, and that page renamed from Billing to Financials.

The sidebar now reads Main, Platform, Operations, Development, Settings. โ€‹

  • System Health left Operations for Development, which is where a developer surface belongs.
  • Development moved down to sit directly above Settings.
  • Nothing sits below Development or Settings, which the move made true rather than something needing its own work.
  • Billing stayed in Operations, under its new name.
  • Nav ids double as routes, so every id, label and icon was carried across unchanged and no route moved.

The Costs tab moved from System Health into Financials, as its own tab. โ€‹

  • It sits between Vendors and Settings, rendered by InfraCosts.jsx.
  • It owns its own fetches rather than reading the page's billingData. The provider breakdown comes from the getSystemHealth callable, whose getBillingHealth maps the BigQuery attribution pipeline into the legacy per-provider shape those cards expect; the other tabs read that same pipeline through analytics-api in its native shape. The OTP numbers come from the auth-api admin route, which no other tab touches. Collapsing the two request paths would mean reshaping the payloads, which is a redesign rather than a move.
  • It renders outside the page's loading and error branches, so a slow or failed analytics-api fetch cannot blank it, and the page Refresh button drives it through a refresh token.

The last two cost blocks left System Health for that tab. โ€‹

  • They overlapped. Usage & Cost Insight on the Overview showed cost per user and cost per venue; Usage to Cost Correlation on the Usage tab showed cost per user again, plus venues per dollar.
  • The union of the distinct metrics now lives once on the Costs tab. Current Monthly Cost was not carried over, because it is the Cost Summary card at the top of that same tab.
  • Both inputs ride in on the getSystemHealth payload the tab already fetches, so this needed no new request.

Billing became Financials, route and all. โ€‹

  • Her call, 2026-08-27: "Billing" reads as what you charge customers, and this page is what we spend. It also leaves the name free for real merchant billing, which the merchant profile already uses at MyMerchantProfile.jsx ("Billing & Plan").
  • /admin/billing became /admin/financials, the nav id and label followed, and the folder and page component were renamed. The old-URL map now sends /billing to /admin/financials.
  • Swapped, not migrated, per her standing call. /admin/billing no longer resolves and falls through to the admin dashboard. There is no compatibility redirect for it by design.
  • The vendor section components (GitHubBilling, GcpBilling, and the rest) keep their names. They render vendor billing data, which is still what that is.

What was decided about the Dashboard? โ€‹

PM DECISION 2026-08-27, made under her autonomy grant and NOT her word, so it is hers to overturn: Shape 3, the split reading page. The admin Dashboard is now a dominant reading column (what we are, plus the release stream out of docs/changelogs/dev/) with a narrow 240px status rail of live counts beside it. The eleven-card grid, its "Available" badges and its duplicated sidebar entries are gone.

  • Why 3 rather than 1 or 2: her own words name both halves at different times. She asked for releases and news, which is the reading column; in the Alerts conversation she said alerts "could potentially be on the dashboard as well", and her alerts are threshold and health state, which is the rail. Shape 1 or 2 answers half of what she has asked for.
  • The reasoning, the rail's sources, the not-optional list, the live run and what it is reversible into are all in dashboard-redesign.md.
  • No alerting was built. The rail is shaped to take a threshold row; the backend does not exist and is separate work.
  • The first cut of the reading column bundled its two source documents into the build, which on Cloudflare Pages published them as static assets served before any authentication. They are fetched through the docs API at runtime now, the column unchanged. That and five smaller review findings are in review-remediation.md.

What did the work turn up that the brainstorm did not predict? โ€‹

AdminShell's back-compat effect that rewrites a #/... hash into a real route runs on MOUNT ONLY. Clicked mid-session, such a link sets a hash fragment and never navigates. Verified in the browser: clicking one leaves the URL at /admin/system#/admin/users with the page unchanged.

  • The Costs tab's "GCP / Firebase Detailed Costs" section, whose whole body was a button linking to #/billing. Removed: self-referential once inside Financials, and already inert.
  • The Overview footer's two quick links. The in-page one is now a plain page hash the tab router already listens for; the cross-page one is a real path.

The System Health Overview's two cost cards pointed at a tab that was leaving. โ€‹

They called onNavigate('billing') for a tab that no longer exists, which would have silently done nothing. They now navigate to /admin/financials#costs.

  • RULED 2026-08-27: both removed. Her words: "Cost spikes are fine but financials stay out of system health in general." Cost is not a health signal. The Four Golden Signals, USE and RED all measure whether the system is working now, while cost is FinOps: a different audience on a different clock. The dead plumbing went with the cards (the DollarSign import, the onOpenCosts handler, the billing local and the cost tooltip).
  • SIDE QUEST, hers, same message: a dedicated alerts page, so anomaly signals are centralized rather than scattered across the surfaces they came from. A cost spike would live there rather than on System Health. Not scoped, not started.

The em dash ratchet keys on file path, so a folder rename reads as 86 new violations. โ€‹

  • Renaming billing/ to financials/ reset 15 grandfathered files to a zero allowance. Fixed by remapping those baseline paths verbatim, counts unchanged, so the ratchet still fails on anything genuinely added.
  • Deliberately not a --update-baseline re-snapshot, which would also grandfather harness/admin-report.json, whose escaping fix she reverted herself in 31988cf0.
  • That file still fails npm run lint:emdash, and it did before this branch. It is the one thing standing between this branch and a green em dash gate, and it is hers to call, since the fix for it was reverted on purpose.

The em dash gate was red for a reason no one had named, and it is now codified. โ€‹

  • The failing file was harness/admin-report.json, a Playwright capture of what the admin portal renders. The portal's Services and CORS page used an em dash as its empty-value placeholder, so the capture recorded seven of them faithfully and the gate failed on the EVIDENCE rather than the source.
  • Three sessions chased the capture instead of the source. One escaped the characters as \u2014; that fix was reverted in 31988cf0; the gate stayed red.
  • Fixed at the source instead: six || '\u2014' placeholders across the admin portal became || '-', matching the eighteen sites that already did. Generated capture output under docs/projects/<project>/harness/*.json is now excluded from the linter, and both traps are written into linter-organization.md so the next session reads them instead of rediscovering them.
  • npm run lint:emdash passes, and the baseline was re-snapshot to lock in the win.

The Uptime tab was measuring almost nothing, and three of its four checks could never pass. โ€‹

Found by reading the tab in the running portal after she said it was missing endpoints. It checked four hardcoded URLs in systemHealth.service.js.

  • None of the seven Cloud Run services were covered, so the tab could report "operational" while saying nothing at all about the backend. The API half now derives from packages/shared/services, which rule 8 already makes every new service join, so a service is monitored the day it is registered.
  • admin.ourlantern.app and storybook.ourlantern.app do not resolve (curl returns 000). Their dev hosts answer 200. Two of the four checks were probing hostnames that were never provisioned, and reporting DOWN for it. The frontend checks now use the same prod-or-dev fallback the registry gives the APIs.
  • The Discord bot has no HTTP listener, anywhere in services/bots/discord. It is a gateway client, so the probe of its Railway /health could never pass and reported the bot DOWN whether or not it was running. Removed rather than left red, because a check that cannot succeed teaches everyone to ignore the tab. It is now unmonitored, and monitoring it for real needs either an HTTP health surface on the bot or a gateway-side check.
  • Cloud Run cold starts would have painted the tab amber. Measured 5.3s for venue-api and 3.7s for auth-api against 0.3s warm, versus an existing 3s degraded bar. API checks now carry an 8s allowance; frontends keep 3s.
  • Nine endpoints now, two frontends plus seven APIs, up from four of which three were broken. The tests derive their counts from the registry too, so a new service cannot silently go unmonitored and cannot break the suite either.

The admin portal reads the DEPLOYED analytics-api, not the local one. โ€‹

Worth knowing before anyone debugs a System Health change again, because the obvious diagnosis is wrong. The chain is SystemHealth.jsx -> getSystemHealth() in firebase.js -> getSystemHealthApi() -> VITE_ANALYTICS_API_URL=/api -> the Vite proxy's /api/analytics -> ANALYTICS_API_ORIGIN, which .env.local points at https://analytics-api-531553779372.us-central1.run.app.

  • So a local edit to systemHealth.service.js can never show in the portal, restart or no restart. The data comes from deployed dev, which runs whatever last merged. There is also a 5-minute server-side cache on admin:systemHealth behind that.
  • Live-verified anyway, on separate ports, per the browser-test skill's branch-only-endpoint path: the branch's analytics-api on 8093 and an admin portal on 3012 with ANALYTICS_API_ORIGIN pointed at it. Her stack on 3001 and 8080 to 8086 was never touched, and both isolated servers were stopped afterwards. Result: nine endpoints, all 200, nine healthy, average 802ms warm. Screenshot: harness/uptime-branch-verified.png.
  • That run is also what set the cold-start threshold. An 8s bar flagged assistant-api at 9.2s on every cold call, so the measured range (2.6s to 9.2s cold, 0.3s to 0.5s warm) put the bar at 12s: above the worst cold start with headroom, so tripping it means genuinely slow rather than merely cold.

Metric cards stretched when a row was sparse. โ€‹

Three grids on System Health forced role-cards-grid--2col, which is repeat(2, 1fr), so two cards rendered 509px wide against the standard 247px. Cloud Storage, Content and Bandwidth now use the default 4-up grid. Verified by measurement: every grid on the Usage, Firebase and Cloudflare tabs now renders 247px per card at 1, 2, 3 and 4 cards.

  • Left alone deliberately: bq-role-cards-grid already caps at minmax(280px, 360px) with justify-content: flex-start, and --3col holds three donut charts, which are a deliberate full-width row rather than sparse metric cards.

The Errors tab was reading a collection nothing has ever written to. โ€‹

She asked whether "No errors logged" was correct after remembering a cloud error during billing aggregation. It was not correct, and it never could have been.

  • getClientErrorsMetrics reads the clientErrors Firestore collection. Live dev holds zero documents in it, and nothing in apps/, services/, packages/, firestore.rules or the Cloud Functions writes to it. The panel said "No errors logged" unconditionally, and its empty state promised "errors are automatically captured when users encounter issues", which was never true.
  • Fixed by reading the store that already has everything. Every Cloud Run service, Cloud Function and Scheduler job already writes failures to Cloud Logging, so getServerErrorsMetrics queries logging.googleapis.com for severity>=ERROR rather than adding a second error store nobody would remember to instrument. Auth is GoogleAuth, which resolves the metadata server when deployed and ADC locally, so it is verifiable off-GCP.
  • Needs roles/logging.viewer on the deployed analytics-api service account. Without it the Logging API answers 403 and the section degrades to not_available with that explanation, rather than failing the health payload.
  • The client half is kept and its empty state now says what it means: "nothing currently writes to the clientErrors collection, so treat this as not instrumented rather than none happened."

It immediately surfaced a real bug: the nightly aggregation job has 404ed for days. โ€‹

34 server errors in 7 days, 4 in the last 24 hours, across 5 sources, on the tab that reported none. The largest group is 20 errors from analytics-daily-aggregation, a Cloud Scheduler job hitting POST /analytics/scheduled/daily-aggregation and getting URL_ERROR-ERROR_NOT_FOUND ... 404, four retries a night at 02:00 UTC.

  • The code predicted it. services/api/analytics/src/routes/scheduled.js says the route was retired in the #616 cleanup and that "the analytics-daily-aggregation Cloud Scheduler job must be deleted alongside this (deploy note in the PR), or it will 404 nightly". The job was never deleted.
  • HERS to action: deleting a Cloud Scheduler job is an infrastructure change on dev GCP, not a code change, and gcloud is not executable from an agent shell here. The route is correctly gone; only the orphaned job remains.
  • Also surfaced: 7 cloud_run_revision, 4 gce_instance, 2 bigquery_resource and 1 bigquery_project error, none of which anything was reporting.

SIDE QUEST, hers 2026-08-27: upstream platform status. โ€‹

Monitor whether the platforms Lantern depends on are themselves down, so a GCP or Cloudflare outage shows up rather than being inferred from our own failures. Distinct from the uptime check, which only proves OUR endpoints answer. Not scoped, not started.

Infrastructure actions taken 2026-08-27, on her explicit permission. โ€‹

Her words: "I give you permission for gcloud deletion and role grant."

  • DELETED the analytics-daily-aggregation Cloud Scheduler job. Verified first that the route is genuinely gone (scheduled.js exposes only aggregate-event-counts-daily, aggregate-ad-delivery-daily and pseudonymize-user-ids), backed up the full job definition before deleting, and confirmed afterwards that the other 13 jobs are untouched. gcloud is not on PATH here; it lives at ~/google-cloud-sdk/bin/gcloud.
  • The roles/logging.viewer grant was NOT needed, so it was not made. analytics-api runs as 531553779372-compute@developer.gserviceaccount.com, which already holds roles/editor, and that role includes logging.logEntries.list (verified against the live role definition, 11979 permissions). Adding the binding would have been redundant IAM.

Yes, there is a resolved-or-not system, and GCP is already running it. โ€‹

Her question: "Is there any way we implement a system on if an error is resolved or not?"

Google Cloud Error Reporting is already enabled on lantern-app-dev and already tracks exactly this. It groups errors by signature and carries resolutionStatus (OPEN, ACKNOWLEDGED, RESOLVED, MUTED), firstSeenTime, lastSeenTime, count and affectedServices. Nothing has ever surfaced it. Five groups are currently OPEN, all application exceptions:

ServiceErrorFirst seenLast seen
merchants-apiZodError2026-04-302026-08-08
auth-apiError: failed with status code 5002026-01-302026-08-03
auth-apiinvalid documentPath resource path2026-08-032026-08-03
analytics-apiERR_MODULE_NOT_FOUND: @lantern/shared2026-02-102026-08-01
assistant-apiZodError2026-07-312026-07-31
  • It is complementary to the Cloud Logging feed already built, not a replacement. Error Reporting only picks up things that look like application exceptions, which is why it shows 5 groups where Logging showed 34 entries: the Scheduler 404s are not exceptions, so they never reach it. Logging is the catch-all with no state; Error Reporting is the stateful subset.
  • The recommendation is to surface Error Reporting groups as the primary Errors view (grouped, with status and first/last seen and a recurrence count) and keep the raw Logging feed underneath as the catch-all. resolutionStatus is writable via PUT v1beta1/{group.name}, so a Resolve control in the portal is possible rather than deep-linking to the GCP console.
  • A resolved group that recurs is the signal that matters, and it comes free: lastSeenTime moving after a group was marked RESOLVED means the fix did not hold.

Client errors: nothing is captured at all. โ€‹

Her question: "Are we capturing any client errors?" Checked across both apps.

  • No window.onerror, no unhandledrejection handler, no Sentry, no captureException anywhere in apps/web/src or apps/admin/src.
  • The admin portal has one ErrorBoundary, and its componentDidCatch only calls console.error. It never sends anywhere.
  • The web PWA, the app users actually run, has no ErrorBoundary at all.
  • The clientErrors collection the old panel read holds zero documents and has no writer, which closes the loop on why it always said "No errors logged".
  • So a user hitting a crash produces nothing anyone can see. Scoped on her ask as its own project: application-monitoring, planning task 4, against the existing #164: Infra: Application Monitoring & Observability. The uptime and server-error work above belongs to that project too; it is recorded here because it came out of this task's nav rearrange.

How was it verified? โ€‹

Headless Playwright against her running local stack (admin on 3001, APIs on 8080 to 8086), driven by the Agent Probe test admin. Screenshots in harness/ prefixed final-.

  • Routes loaded: /admin/financials and its #overview, #reports, #vendors/gcp, #settings and #costs hashes; /admin/system; /admin/users; /admin/analytics; /admin/feature-tracker; /admin/moderation; /admin/config/overview; /admin/api; /admin/client-sdk; plus the old /billing (redirects correctly) and /admin/billing, which fell through to the Dashboard. That fall-through was recorded here as intended and it was not: the branch review caught it as a dead route and it now redirects to /admin/financials, see review-remediation.md.
  • Every System Health tab and every Financials tab was clicked and rendered with no page errors.
  • The Costs tab renders live data: total $298.64, five provider cards, cost per user $8.53, cost per venue $0.01, venues per dollar 76, and OTP at 10/2000 sends, 8 verified, $0.28 estimated spend.
  • The repointed System Health cost card lands on /admin/financials#costs.
  • npm run test -w apps/admin on the touched suites: 32 passed.

What was fixed on 2026-08-28? โ€‹

  • footer-pin-988.md, why the merchant sidebar footer rose up (#988) and the computed styles that proved it. The cause was the previous day's M5 fix, and the answer reverses M5's chosen option because the operator's own words pick the other one.
  • merchant-defect-survey-0828.md, the goal-two sweep: six defects fixed, seven reported with a reason for each, and the two places a fix wanted to reach into apps/admin/src/shared.
  • ../../engineering/testing/runs/merchant-portal-defects-2026-08-28/README.md, fourteen scenarios, all filled in, including the three that could have said the diagnosis was wrong.
  • Decided 2026-08-28, leave it: the merchant rail now has 407.9px between "Settings" and the pinned footer, which is M5 returning by design. She chose the empty space over filling it, so the survey's third option is declined and survey item M5 closes rather than staying open.

What does the design still need? โ€‹

  • Her verdict, 2026-08-28: the merchant design needs a considerable overhaul on every page except the offer creation and builder flow.
  • merchant-design-brief.md is the evidence for it: all seven routes measured at 1440x950 with their screenshots, seven observations, and the four questions a decision would need to answer. It proposes no layouts, because the shape is hers to name.
  • The headline number: content fill runs from 7.8% on Settings to 84.4% on Overview, and five of the seven pages leave more than half the window empty below their last block.
  • Three directions are drawn and waiting on her, 2026-08-30: directions/index.html puts them side by side, and each one is its own file in directions/. They open straight from disk: no server, no CDN, no network call, tokens and the Inter face inlined from the repo. A. Fewer doors, seven routes become three. B. Sized to content, the container follows the content. C. Data led, every page carries the data it is about. Each names its own tradeoff, and none of them is picked.
  • They are mockups, not app code. Nothing is being built against this until she names the shape.
  • Venue Activity period stats come from BigQuery analytics.events via getPlatformOverviewBq and getVenueRankingsBq, not from Firestore. An earlier "the zeros are real" diagnosis was wrong; the Firestore queries had three field-name bugs.

Built with VitePress