Short answer: build the admin dashboard around three read-or-command boundaries — open error groups, latest events, and an explicitly reversible resolve action — and never let the UI mutate an error group from stale data.
For a property-management SaaS, the important signal is not merely that a scheduled import threw an exception. It is that a property feed stopped producing results. A failed parser, a valid empty file, and a scheduler that never started can all leave the downstream property list unchanged, yet they require different responses. The dashboard therefore needs to preserve evidence about execution and output while keeping the operator's action narrow enough to undo.
My decision is to use a server-owned error-group state machine, an append-only event history, and optimistic concurrency on resolution. The browser is a view and command surface. It is not the authority. That boundary costs an extra read after a conflict, but rollback safety matters more than shaving one request from an internal tool.
What the architecture decision protects
The first invariant is about meaning: an error group represents a recurring failure identity, while an event represents one occurrence. A group may be open even when its latest event is old. Conversely, a recent successful run should not silently erase an open operational decision. Import health belongs beside error state, not inside an overloaded last_seen field.
The second invariant is about history. Resolving a group appends an auditable state transition; it does not delete events. Undo appends another transition that refers to the resolution it reverses. This is intentionally boring. When an operator resolves the wrong group during a busy import window, the recovery operation should be a normal command with the same authorization and concurrency checks, not a database restoration exercise.
Keep deletion out of this path.
The third invariant limits telemetry. Store only the fields needed to identify the import, diagnose its failure class, order occurrences, and link the result to an authorized property-management scope. Raw tenant files, resident details, access tokens, and unrestricted exception payloads do not belong in an error dashboard. The data-minimization principle in GDPR Article 5 is a useful design constraint here: personal data should be adequate, relevant, and limited to what is necessary for the purpose.
These invariants create clear failure boundaries. The collector may accept an event without changing operator state. The grouping worker may be delayed without making a resolve command ambiguous. The read model may lag, but a stale version cannot overwrite a newer decision. The command service owns transitions and returns the current representation after a successful write. If the supplied version is no longer current, the client treats the result as a conflict, refreshes, and asks the human to decide again.
How should an internal SaaS admin dashboard connect open error groups, latest events, and resolve actions?
Use separate query and command contracts, even if one application serves all three. A compact list query should return group identity, status, latest occurrence time, affected import identity, event count within the dashboard's defined window, and a version token. Selecting a row requests a bounded page of recent events. Resolving sends the version already displayed plus a short reason; undo sends the resulting transition identifier. This keeps cardinality visible and payload growth predictable.
The interface can be reasoned about as three APIs, although the exact URL vocabulary is local to the service:
| Boundary | Minimum input | Minimum output | Failure boundary | Rollback property |
|---|---|---|---|---|
| Open-group query | Tenant scope, status, cursor, page limit | Group summary, windowed count, latest event, version | A delayed projection can be stale | No mutation |
| Latest-event query | Group ID, cursor, page limit | Redacted event summaries and next cursor | Event detail can be unavailable independently of group state | No mutation |
| Resolve command | Group ID, displayed version, reason, idempotency key | New status, new version, transition ID | A concurrent command must not be overwritten | Transition can be reversed by ID |
The table hides one important modeling choice: “latest” needs an ordering rule. Use a server-assigned ingestion sequence as the tie-breaker after event time, because two events can carry the same timestamp and client clocks are outside the dashboard's control. The UI may display event time, but pagination should follow a stable server cursor. Do not derive the next page from a timestamp alone.
For the scheduled-import job, I would expose result production as data rather than inference. An event summary can include run_id, import_id, started_at, finished_at, outcome, and result_count, with a redacted error class when the outcome is a failure. A monitor can then distinguish “the run failed,” “the run completed with zero results,” and “no run was observed by the deadline.” Those are three operational states, not three phrasings of the same exception.
Cardinality deserves equal attention. A grouping key built from full messages, file names, property IDs, or stack text can create a new group for every occurrence. Prefer a bounded tuple such as component, operation, normalized error class, and parser schema version. Keep high-cardinality values on the event record, where retention and access can be controlled separately. The grouping key should change only when the response procedure changes; otherwise the dashboard fragments one incident into many rows and makes both operator judgment and storage forecasting worse.
No grouping key is perfect.
I'm not sure a single normalization rule will survive every importer, especially when third-party formats evolve. The evidence that resolves that uncertainty is a replay set of redacted failures: compare candidate keys, count accidental splits and merges, and require review before changing the grouping version. Your mileage may vary by feed diversity, but the migration rule should not: preserve the old key and record the new grouping version so rollback does not rewrite history.
The critical path in curl
The following contract is illustrative, not a claim about a public vendor API. It shows the properties that matter: bounded reads, explicit scope, concurrency, idempotency, and a reversible transition. The three calls fit the operator's actual sequence without letting the browser manufacture state.
BASE_URL="https://admin-api.example.test"
TENANT_ID="tenant_demo"
GROUP_ID="grp_import_parser"
GROUP_VERSION="17"
curl --fail-with-body --silent --show-error \
--get "${BASE_URL}/admin/error-groups" \
--header "Authorization: Bearer ${ADMIN_TOKEN}" \
--data-urlencode "tenant_id=${TENANT_ID}" \
--data-urlencode "status=open" \
--data-urlencode "limit=25"
curl --fail-with-body --silent --show-error \
--get "${BASE_URL}/admin/error-groups/${GROUP_ID}/events" \
--header "Authorization: Bearer ${ADMIN_TOKEN}" \
--data-urlencode "limit=20"
curl --fail-with-body --silent --show-error \
--request POST "${BASE_URL}/admin/error-groups/${GROUP_ID}/resolve" \
--header "Authorization: Bearer ${ADMIN_TOKEN}" \
--header "Content-Type: application/json" \
--header "Idempotency-Key: resolve-grp-import-parser-17" \
--data '{"expected_version":17,"reason":"import format corrected and replay verified"}'
The resolve response should carry a new version and a transition ID. The UI stores neither as global mutable state; it associates them with the row that produced the command. Undo is then another authorized command against that transition, subject to a fresh version check. A second click with the same idempotency key returns the same logical outcome rather than creating a second transition.
Do not automatically retry a version conflict. Refresh the row and show what changed. Automatic retry is appropriate for a transport interruption only when the same idempotency key is retained, because the client does not know whether the command crossed the boundary before the connection ended. This distinction is small in code and large in operational meaning — one case repeats an observation, while the other risks overruling another operator.
Consider the concurrency case from the operator's point of view. One administrator opens a group at version 17, reads the latest event, and begins checking the affected property's import configuration. A second administrator opens the same version and resolves the group after verifying a corrected file. The first tab is now stale. If its resolve button performs an unconditional update, it can replace the newer reason, blur who made the decision, and leave no precise action to reverse. With the proposed command boundary, the first tab sends expected_version: 17, receives a conflict because the current version has advanced, and refreshes the group before offering another decision. If the second administrator instead loses the network response after the service accepts the command, repeating the request with the original idempotency key identifies the accepted transition. These cases can look identical as a spinner in the browser, but they are not equivalent at the command boundary: one requires new human judgment, while the other requires retrieval of an already determined outcome.
Conflicts stop here.
The dashboard should also avoid claiming that resolution repaired the import. “Resolved” records a human workflow decision. Import recovery needs separate evidence: a later run completed, its result count met the local acceptance rule, and the output was published. Coupling those facts would encourage operators to resolve an alert merely to clear the screen, or would reopen a deliberately closed group whenever an unrelated event arrived.
Retention, sampling, and deployment checks
Telemetry cost starts with multiplication: retained bytes equal event rate multiplied by average stored bytes and retention duration, plus index and replication overhead defined by the chosen storage system. Keep those terms visible rather than hiding them in one monthly estimate. Group summaries and state transitions are small, durable operational records; event bodies are the volume term. They can have different retention policies because they answer different questions.
Sampling errors before grouping can erase a low-frequency failure completely, so it is a poor default for the signal “scheduled imports stopped producing results.” Group first, retain a bounded diagnostic sample per group and time window, and maintain exact aggregate counters only where the collector contract can support them. If sampling is unavoidable, mark counts as estimates and preserve the sampling decision with the event. Don't present sampled event counts as exact facts.
The catch is that longer retention is useful for intermittent monthly imports, while data minimization and storage cost push the other way. Choose retention from the longest justified diagnostic interval, then test that assumption with access logs and incident review. A property manager who imports every hour has a different useful window from one who receives a monthly owner statement. One global duration is easy to administer, but it may be unsuitable when tenant workflows differ materially; use policy tiers when those differences are stable and authorized.
Before deployment, test the state machine rather than only the happy-path page. Two resolve commands with version 17 must produce one accepted transition and one conflict. Repeating the accepted command with the same idempotency key must identify the same logical transition. Undo must target a specific transition, and an unauthorized tenant scope must reveal no group existence. Cursor tests need tied event timestamps, late arrivals, redacted fields, and page boundaries. Finally, simulate a run that fails, a run that produces zero results, and a missing run; the dashboard must keep those states distinct.
Native desktop clients add another boundary. Electron's crashReporter documentation describes native crash reports and minidumps, which are different artifacts from application-level import events. If such a client participates in the workflow, ingest its crash metadata through a separately governed path and link only a permitted correlation identifier. A minidump is not an error-group payload, and broad collection would undermine the minimization rule that shapes this design.
Why I rejected direct row mutation
The rejected option is a dashboard that reads an error-group table and changes status directly. It has an attractive property: fewer components. For a tiny internal system with one operator, no audit requirement, and a database whose own history is the accepted recovery mechanism, that design can be valid. Stick with it when reversibility is deliberately provided below the application and the team has tested restoration at the required granularity.
It is not suitable here. Property-management imports cross tenant boundaries, resolution is an operational assertion rather than a cosmetic flag, and a stale tab can race with another operator. Direct mutation has nowhere natural to attach an idempotency key, an expected version, a reason, and a reversible transition without slowly recreating a command model inside database conventions.
The chosen architecture has limitations too. It adds a projection, version handling, transition storage, and more test cases. It will not make a bad grouping key correct, and it cannot prove that a successful import produced semantically correct property data. Those checks belong to schema validation and domain reconciliation. The architecture earns its complexity only where mistaken or concurrent resolution must be recoverable; otherwise the simpler option remains defensible.
The final decision rule is narrow: use the three-boundary dashboard when an operator must inspect current groups, read bounded evidence, and reverse a state decision without rewriting history. Keep import success separate, make cardinality and retention explicit, and let a conflict stop the click rather than silently win it.
Top comments (0)