Add macOS dashboard and persistent IP enrichment cache#11
Conversation
Cache PTR/ASN/country lookups to {data_dir}/.enrich_cache.jsonl so
repeated fetches and CLI/app views skip redundant DNS work. Entries
expire after 30 days.
- JSONL append-only writes keep per-update cost O(1)
- Automatic compaction when duplicate lines exceed unique entries
- Thread-safe via internal mutex; parallel DNS workers can update concurrently
- fetch command and C ABI reports_fetch run enrichment across all
stored source IPs in parallel after IMAP fetch completes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- New DashboardView split into DMARC (left) and MTA-STS (right) columns with org donut charts, per-domain DKIM/SPF/TLS pass/fail bars, and disposition/policy/failure type distributions (Swift Charts) - Sidebar reorganized: Dashboard / All / DMARC / TLS-RPT as a single group with matching SF Symbol icons - Detail view moved below the list (40% of window height) with an inline close button; 3-column layout reduced to 2 columns - Detail loading and IP enrichment now run asynchronously off the main thread with a progress spinner; enrichments populate progressively from the ViewModel-shared dictionary so row clicks feel instant Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix TOCTOU race in reports_enrich_ip by holding g_cache_mu across cache access, preventing use-after-free if reports_deinit runs concurrently. - Clean up orphaned .enrich_cache.jsonl.tmp files on Cache.init so a mid-compaction crash doesn't leave stale artifacts. - Propagate appendLine errors from Cache.put and roll back the in-memory map entry on failure, keeping disk and memory consistent and surfacing disk-full / permission errors instead of silently dropping them. - Replace fragile .task(id: entries.count) trigger in DashboardView with entriesVersion counter that increments on every loadReports, so the dashboard reloads even when contents change without affecting count. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new macOS “Dashboard” view and introduces a persistent, shared IP enrichment cache to eliminate repeated DNS/ASN lookups and improve UI responsiveness during report detail viewing.
Changes:
- Added a persistent JSONL-based IP enrichment cache with TTL, compaction, and parallel enrichment workers.
- Updated CLI and C-ABI fetch paths to collect unique source IPs and enrich them in parallel post-fetch.
- Added a macOS Dashboard screen and reworked the detail UI to load asynchronously with progressive enrichment.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| src/root.zig | Exposes the new enrichcache module in the root imports. |
| src/main.zig | After CLI fetch, collects unique IPs and runs parallel enrichment with progress output. |
| src/lib.zig | Adds a lazily initialized global enrichment cache shared by reports_fetch and reports_enrich_ip. |
| src/fetch.zig | Adds utilities to scan stored reports and collect unique source IPs. |
| src/enrichcache.zig | Implements persistent JSONL cache, TTL logic, compaction, parallel enrichment, and tests. |
| macos/Reports/Views/SidebarView.swift | Adds a Dashboard navigation option and updates selection logic when Dashboard is active. |
| macos/Reports/Views/ReportDetailView.swift | Moves to async loading with spinner, uses view-model enrichments instead of synchronous enrichment in body. |
| macos/Reports/Views/DashboardView.swift | New dashboard with aggregated DMARC/TLS-RPT charts and per-domain summaries. |
| macos/Reports/Views/ContentView.swift | Shows Dashboard in detail column; when a row is selected, places detail below list with responsive sizing. |
| macos/Reports/ReportsViewModel.swift | Adds dashboard state, async detail loading, and background enrichment publishing results progressively. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| // After all accounts fetched, enrich every unique source IP in parallel | ||
| // using the same worker-pool approach as IMAP fetch. Results are cached | ||
| // persistently at {data_dir}/.enrich_cache.json for future runs. |
There was a problem hiding this comment.
Comment says the cache is persisted at {data_dir}/.enrich_cache.json, but the implementation writes .enrich_cache.jsonl (JSON Lines). Update the comment to match the actual filename/format so users can find the cache file.
| // persistently at {data_dir}/.enrich_cache.json for future runs. | |
| // persistently at {data_dir}/.enrich_cache.jsonl (JSON Lines) for future runs. |
| const file = std.fs.createFileAbsolute(self.path, .{ .truncate = false }) catch return; | ||
| defer file.close(); | ||
| file.seekFromEnd(0) catch {}; |
There was a problem hiding this comment.
appendLine swallows file-open/seek errors (catch return / catch {}), so put() can report success while failing to persist to disk (and without rolling back the in-memory insert). Since put() relies on appendLine error propagation for consistency, these errors should be returned (use try for createFileAbsolute and seekFromEnd).
| const file = std.fs.createFileAbsolute(self.path, .{ .truncate = false }) catch return; | |
| defer file.close(); | |
| file.seekFromEnd(0) catch {}; | |
| const file = try std.fs.createFileAbsolute(self.path, .{ .truncate = false }); | |
| defer file.close(); | |
| try file.seekFromEnd(0); |
| const entry = Entry{ | ||
| .ptr = dupStrField(self.allocator, obj.get("ptr")) catch continue, | ||
| .asn = dupStrField(self.allocator, obj.get("asn")) catch continue, | ||
| .asn_org = dupStrField(self.allocator, obj.get("asn_org")) catch continue, | ||
| .country = dupStrField(self.allocator, obj.get("country")) catch continue, | ||
| .ts = tsField(obj.get("ts")), | ||
| }; | ||
|
|
||
| // Later lines overwrite earlier ones (JSONL "last wins") | ||
| if (self.map.fetchRemove(ip)) |old| { | ||
| self.allocator.free(old.key); | ||
| freeEntryFields(self.allocator, old.value); | ||
| } | ||
| const ip_copy = self.allocator.dupe(u8, ip) catch { | ||
| freeEntryFields(self.allocator, entry); | ||
| continue; | ||
| }; | ||
| self.map.put(ip_copy, entry) catch { | ||
| self.allocator.free(ip_copy); | ||
| freeEntryFields(self.allocator, entry); |
There was a problem hiding this comment.
In loadFromDisk, constructing entry with multiple dupStrField(... ) catch continue can leak earlier-allocated fields if a later field allocation fails (e.g., OOM), because the partially built Entry is abandoned without freeing. Consider building the entry with errdefer (or allocating fields into temporaries and freeing on failure) before assigning into the map.
| const entry = Entry{ | |
| .ptr = dupStrField(self.allocator, obj.get("ptr")) catch continue, | |
| .asn = dupStrField(self.allocator, obj.get("asn")) catch continue, | |
| .asn_org = dupStrField(self.allocator, obj.get("asn_org")) catch continue, | |
| .country = dupStrField(self.allocator, obj.get("country")) catch continue, | |
| .ts = tsField(obj.get("ts")), | |
| }; | |
| // Later lines overwrite earlier ones (JSONL "last wins") | |
| if (self.map.fetchRemove(ip)) |old| { | |
| self.allocator.free(old.key); | |
| freeEntryFields(self.allocator, old.value); | |
| } | |
| const ip_copy = self.allocator.dupe(u8, ip) catch { | |
| freeEntryFields(self.allocator, entry); | |
| continue; | |
| }; | |
| self.map.put(ip_copy, entry) catch { | |
| self.allocator.free(ip_copy); | |
| freeEntryFields(self.allocator, entry); | |
| var entry = Entry{ | |
| .ptr = "", | |
| .asn = "", | |
| .asn_org = "", | |
| .country = "", | |
| .ts = tsField(obj.get("ts")), | |
| }; | |
| errdefer freeEntryFields(self.allocator, entry); | |
| entry.ptr = dupStrField(self.allocator, obj.get("ptr")) catch continue; | |
| entry.asn = dupStrField(self.allocator, obj.get("asn")) catch continue; | |
| entry.asn_org = dupStrField(self.allocator, obj.get("asn_org")) catch continue; | |
| entry.country = dupStrField(self.allocator, obj.get("country")) catch continue; | |
| const final_entry = entry; | |
| errdefer comptime unreachable; | |
| // Later lines overwrite earlier ones (JSONL "last wins") | |
| if (self.map.fetchRemove(ip)) |old| { | |
| self.allocator.free(old.key); | |
| freeEntryFields(self.allocator, old.value); | |
| } | |
| const ip_copy = self.allocator.dupe(u8, ip) catch { | |
| freeEntryFields(self.allocator, final_entry); | |
| continue; | |
| }; | |
| self.map.put(ip_copy, final_entry) catch { | |
| self.allocator.free(ip_copy); | |
| freeEntryFields(self.allocator, final_entry); |
| } | ||
| .padding(.horizontal, 12) | ||
| .padding(.vertical, 8) | ||
| .background(.background.secondary) |
There was a problem hiding this comment.
.background(.background.secondary) is likely not a valid ShapeStyle/Color combination and may fail to compile (there’s no other usage of .background.secondary in the project). Use a concrete color/material for the header background (e.g., a system control/window background color or a material) that matches the app’s styling.
| .background(.background.secondary) | |
| .background(Color(nsColor: .controlBackgroundColor)) |
| func selectDashboard() { | ||
| filterType = nil | ||
| filterAccount = nil | ||
| filterDomain = nil | ||
| selectedEntryID = nil | ||
| detailJSON = nil | ||
| showDashboard = true | ||
| } |
There was a problem hiding this comment.
selectDashboard() clears selection and detailJSON but doesn’t cancel detailTask / enrichmentTask or reset isLoadingDetail. If the user switches to the Dashboard while a detail load/enrichment is running, background work will keep running and may mutate state unexpectedly. Consider cancelling the in-flight tasks (or calling closeDetail()) when entering Dashboard mode.
| const Spawner = struct { | ||
| fn run(c: *reports.enrichcache.Cache, a: std.mem.Allocator, batch: []const []const u8, p: *std.atomic.Value(usize)) void { | ||
| reports.enrichcache.enrichParallel(c, a, batch, p); | ||
| } | ||
| }; | ||
|
|
||
| const thread = std.Thread.spawn(.{}, Spawner.run, .{ &cache, allocator, ips, &progress }) catch { | ||
| reports.enrichcache.enrichParallel(&cache, allocator, ips, &progress); | ||
| return; |
There was a problem hiding this comment.
enrichParallel spawns multiple threads that call ipinfo.lookup(ctx.allocator, ...). In the CLI this allocator is a GeneralPurposeAllocator (see main()), which is not thread-safe; this can cause data races/UB during parallel enrichment. Use a thread-safe allocator for the worker lookups (e.g., std.heap.c_allocator or a std.heap.ThreadSafeAllocator wrapper) and keep the non-thread-safe allocator for the single-threaded parts (collecting IPs, etc.).
| var dmarcOrgs: [String: Int] = [:] | ||
| var tlsrptOrgs: [String: Int] = [:] | ||
|
|
||
| // Per-domain DMARC aggregates | ||
| var dkimPass: [String: UInt64] = [:] | ||
| var dkimFail: [String: UInt64] = [:] | ||
| var spfPass: [String: UInt64] = [:] | ||
| var spfFail: [String: UInt64] = [:] | ||
| var dispositions: [String: UInt64] = [:] | ||
|
|
||
| // Per-domain TLS aggregates | ||
| var tlsSuccess: [String: UInt64] = [:] | ||
| var tlsFailure: [String: UInt64] = [:] | ||
| var tlsPolicyTypes: [String: UInt64] = [:] | ||
| var tlsFailureTypes: [String: UInt64] = [:] | ||
|
|
||
| let core = ReportsCore.shared | ||
| let decoder = JSONDecoder() | ||
|
|
||
| for (index, entry) in entries.enumerated() { | ||
| switch entry.type { | ||
| case .dmarc: | ||
| dmarcOrgs[entry.org, default: 0] += 1 | ||
| if let json = try? core.show(type: .dmarc, account: entry.account, filename: entry.filename), | ||
| let data = json.data(using: .utf8), | ||
| let detail = try? decoder.decode(DmarcDetail.self, from: data) { | ||
| let domain = detail.policy.domain.isEmpty ? entry.domain : detail.policy.domain | ||
| for rec in detail.records { | ||
| if rec.dkim_eval == "pass" { | ||
| dkimPass[domain, default: 0] += rec.count | ||
| } else { | ||
| dkimFail[domain, default: 0] += rec.count | ||
| } | ||
| if rec.spf_eval == "pass" { | ||
| spfPass[domain, default: 0] += rec.count | ||
| } else { | ||
| spfFail[domain, default: 0] += rec.count | ||
| } | ||
| let disp = rec.disposition.isEmpty ? "none" : rec.disposition | ||
| dispositions[disp, default: 0] += rec.count | ||
| } | ||
| } | ||
| case .tlsrpt: | ||
| tlsrptOrgs[entry.org, default: 0] += 1 | ||
| if let json = try? core.show(type: .tlsrpt, account: entry.account, filename: entry.filename), | ||
| let data = json.data(using: .utf8), | ||
| let detail = try? decoder.decode(TlsDetail.self, from: data) { | ||
| for policy in detail.policies { | ||
| let domain = policy.policy_domain.isEmpty ? entry.domain : policy.policy_domain | ||
| let type = policy.policy_type.isEmpty ? "unknown" : policy.policy_type | ||
| tlsPolicyTypes[type, default: 0] += policy.total_successful + policy.total_failure | ||
| tlsSuccess[domain, default: 0] += policy.total_successful | ||
| tlsFailure[domain, default: 0] += policy.total_failure | ||
| for f in policy.failures { | ||
| let ft = f.result_type.isEmpty ? "unknown" : f.result_type | ||
| tlsFailureTypes[ft, default: 0] += f.failed_session_count | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if index % 25 == 0 { | ||
| await Task.yield() | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
DashboardStatsLoader.load runs on @MainActor but does synchronous I/O/decoding per entry (core.show(...) + JSON decode) inside the loop. Even with Task.yield(), this can still block the main thread and make the dashboard feel janky on large datasets. Move the aggregation work off the main actor (e.g., compute in a detached task / background queue) and then publish stats back on MainActor.
| var dmarcOrgs: [String: Int] = [:] | |
| var tlsrptOrgs: [String: Int] = [:] | |
| // Per-domain DMARC aggregates | |
| var dkimPass: [String: UInt64] = [:] | |
| var dkimFail: [String: UInt64] = [:] | |
| var spfPass: [String: UInt64] = [:] | |
| var spfFail: [String: UInt64] = [:] | |
| var dispositions: [String: UInt64] = [:] | |
| // Per-domain TLS aggregates | |
| var tlsSuccess: [String: UInt64] = [:] | |
| var tlsFailure: [String: UInt64] = [:] | |
| var tlsPolicyTypes: [String: UInt64] = [:] | |
| var tlsFailureTypes: [String: UInt64] = [:] | |
| let core = ReportsCore.shared | |
| let decoder = JSONDecoder() | |
| for (index, entry) in entries.enumerated() { | |
| switch entry.type { | |
| case .dmarc: | |
| dmarcOrgs[entry.org, default: 0] += 1 | |
| if let json = try? core.show(type: .dmarc, account: entry.account, filename: entry.filename), | |
| let data = json.data(using: .utf8), | |
| let detail = try? decoder.decode(DmarcDetail.self, from: data) { | |
| let domain = detail.policy.domain.isEmpty ? entry.domain : detail.policy.domain | |
| for rec in detail.records { | |
| if rec.dkim_eval == "pass" { | |
| dkimPass[domain, default: 0] += rec.count | |
| } else { | |
| dkimFail[domain, default: 0] += rec.count | |
| } | |
| if rec.spf_eval == "pass" { | |
| spfPass[domain, default: 0] += rec.count | |
| } else { | |
| spfFail[domain, default: 0] += rec.count | |
| } | |
| let disp = rec.disposition.isEmpty ? "none" : rec.disposition | |
| dispositions[disp, default: 0] += rec.count | |
| } | |
| } | |
| case .tlsrpt: | |
| tlsrptOrgs[entry.org, default: 0] += 1 | |
| if let json = try? core.show(type: .tlsrpt, account: entry.account, filename: entry.filename), | |
| let data = json.data(using: .utf8), | |
| let detail = try? decoder.decode(TlsDetail.self, from: data) { | |
| for policy in detail.policies { | |
| let domain = policy.policy_domain.isEmpty ? entry.domain : policy.policy_domain | |
| let type = policy.policy_type.isEmpty ? "unknown" : policy.policy_type | |
| tlsPolicyTypes[type, default: 0] += policy.total_successful + policy.total_failure | |
| tlsSuccess[domain, default: 0] += policy.total_successful | |
| tlsFailure[domain, default: 0] += policy.total_failure | |
| for f in policy.failures { | |
| let ft = f.result_type.isEmpty ? "unknown" : f.result_type | |
| tlsFailureTypes[ft, default: 0] += f.failed_session_count | |
| } | |
| } | |
| } | |
| } | |
| if index % 25 == 0 { | |
| await Task.yield() | |
| } | |
| } | |
| struct Aggregates { | |
| let dmarcOrgs: [String: Int] | |
| let tlsrptOrgs: [String: Int] | |
| let dkimPass: [String: UInt64] | |
| let dkimFail: [String: UInt64] | |
| let spfPass: [String: UInt64] | |
| let spfFail: [String: UInt64] | |
| let dispositions: [String: UInt64] | |
| let tlsSuccess: [String: UInt64] | |
| let tlsFailure: [String: UInt64] | |
| let tlsPolicyTypes: [String: UInt64] | |
| let tlsFailureTypes: [String: UInt64] | |
| } | |
| let aggregates = await withCheckedContinuation { continuation in | |
| DispatchQueue.global(qos: .userInitiated).async { | |
| var dmarcOrgs: [String: Int] = [:] | |
| var tlsrptOrgs: [String: Int] = [:] | |
| // Per-domain DMARC aggregates | |
| var dkimPass: [String: UInt64] = [:] | |
| var dkimFail: [String: UInt64] = [:] | |
| var spfPass: [String: UInt64] = [:] | |
| var spfFail: [String: UInt64] = [:] | |
| var dispositions: [String: UInt64] = [:] | |
| // Per-domain TLS aggregates | |
| var tlsSuccess: [String: UInt64] = [:] | |
| var tlsFailure: [String: UInt64] = [:] | |
| var tlsPolicyTypes: [String: UInt64] = [:] | |
| var tlsFailureTypes: [String: UInt64] = [:] | |
| let core = ReportsCore.shared | |
| let decoder = JSONDecoder() | |
| for entry in entries { | |
| switch entry.type { | |
| case .dmarc: | |
| dmarcOrgs[entry.org, default: 0] += 1 | |
| if let json = try? core.show(type: .dmarc, account: entry.account, filename: entry.filename), | |
| let data = json.data(using: .utf8), | |
| let detail = try? decoder.decode(DmarcDetail.self, from: data) { | |
| let domain = detail.policy.domain.isEmpty ? entry.domain : detail.policy.domain | |
| for rec in detail.records { | |
| if rec.dkim_eval == "pass" { | |
| dkimPass[domain, default: 0] += rec.count | |
| } else { | |
| dkimFail[domain, default: 0] += rec.count | |
| } | |
| if rec.spf_eval == "pass" { | |
| spfPass[domain, default: 0] += rec.count | |
| } else { | |
| spfFail[domain, default: 0] += rec.count | |
| } | |
| let disp = rec.disposition.isEmpty ? "none" : rec.disposition | |
| dispositions[disp, default: 0] += rec.count | |
| } | |
| } | |
| case .tlsrpt: | |
| tlsrptOrgs[entry.org, default: 0] += 1 | |
| if let json = try? core.show(type: .tlsrpt, account: entry.account, filename: entry.filename), | |
| let data = json.data(using: .utf8), | |
| let detail = try? decoder.decode(TlsDetail.self, from: data) { | |
| for policy in detail.policies { | |
| let domain = policy.policy_domain.isEmpty ? entry.domain : policy.policy_domain | |
| let type = policy.policy_type.isEmpty ? "unknown" : policy.policy_type | |
| tlsPolicyTypes[type, default: 0] += policy.total_successful + policy.total_failure | |
| tlsSuccess[domain, default: 0] += policy.total_successful | |
| tlsFailure[domain, default: 0] += policy.total_failure | |
| for f in policy.failures { | |
| let ft = f.result_type.isEmpty ? "unknown" : f.result_type | |
| tlsFailureTypes[ft, default: 0] += f.failed_session_count | |
| } | |
| } | |
| } | |
| } | |
| } | |
| continuation.resume( | |
| returning: Aggregates( | |
| dmarcOrgs: dmarcOrgs, | |
| tlsrptOrgs: tlsrptOrgs, | |
| dkimPass: dkimPass, | |
| dkimFail: dkimFail, | |
| spfPass: spfPass, | |
| spfFail: spfFail, | |
| dispositions: dispositions, | |
| tlsSuccess: tlsSuccess, | |
| tlsFailure: tlsFailure, | |
| tlsPolicyTypes: tlsPolicyTypes, | |
| tlsFailureTypes: tlsFailureTypes | |
| ) | |
| ) | |
| } | |
| } | |
| let dmarcOrgs = aggregates.dmarcOrgs | |
| let tlsrptOrgs = aggregates.tlsrptOrgs | |
| let dkimPass = aggregates.dkimPass | |
| let dkimFail = aggregates.dkimFail | |
| let spfPass = aggregates.spfPass | |
| let spfFail = aggregates.spfFail | |
| let dispositions = aggregates.dispositions | |
| let tlsSuccess = aggregates.tlsSuccess | |
| let tlsFailure = aggregates.tlsFailure | |
| let tlsPolicyTypes = aggregates.tlsPolicyTypes | |
| let tlsFailureTypes = aggregates.tlsFailureTypes |
| detailTask = Task { [weak self] in | ||
| guard let self else { return } | ||
| let json: String? = await Task.detached(priority: .userInitiated) { [core] in | ||
| try? core.show(type: entry.type, account: entry.account, filename: entry.filename) | ||
| }.value | ||
|
|
||
| if Task.isCancelled { return } | ||
|
|
||
| await MainActor.run { | ||
| self.detailJSON = json | ||
| self.isLoadingDetail = false | ||
| } |
There was a problem hiding this comment.
loadDetail converts core.show(...) into an optional with try?. If loading fails, json stays nil and isLoadingDetail is set to false, but the UI currently treats detailJSON == nil as “still loading”, so the spinner can remain forever and the error is never surfaced. Preserve the thrown error (set errorMessage, or store a distinct “failed” state) and update the detail view to show an error/empty state when loading completes with nil.
| enrichmentTask = Task { [weak self, core] in | ||
| for ip in pending { | ||
| if Task.isCancelled { return } | ||
| let result = await Task.detached(priority: .userInitiated) { | ||
| core.enrichIP(ip) | ||
| }.value |
There was a problem hiding this comment.
startEnrichment calls core.enrichIP inside Task.detached. ReportsCore.enrichIP mutates an internal enrichCache dictionary, which is not synchronized; if a previous enrichment task is cancelled while a detached call is in-flight (or a new enrichment starts quickly), you can end up with concurrent enrichIP calls mutating the dictionary from multiple threads. Guard enrichCache with a lock/actor, or remove the Swift-side cache now that enrichment is cached persistently in Zig.
| fn getCache(data_dir: []const u8) ?*reports.enrichcache.Cache { | ||
| g_cache_mu.lock(); | ||
| defer g_cache_mu.unlock(); | ||
| if (g_cache == null) { | ||
| g_cache = reports.enrichcache.Cache.init(allocator, data_dir) catch return null; | ||
| } | ||
| return &g_cache.?; |
There was a problem hiding this comment.
getCache(data_dir:) only uses data_dir the first time it’s called; subsequent calls will reuse the existing cache even if a different data_dir is passed (e.g., if the host app provides a different config JSON later). That can cause enrichment results to be read/written to the wrong cache file. Consider either keying the global cache by data_dir (map of caches) or detecting a data_dir change and reinitializing the cache accordingly.
- Fix comment: .enrich_cache.json → .enrich_cache.jsonl (src/main.zig) - Use try for file open/seek in appendLine so I/O errors propagate consistently through put() (src/enrichcache.zig) - Fix memory leak in loadFromDisk: free earlier-allocated fields when a later dupStrField fails (src/enrichcache.zig) - Cancel in-flight detail/enrichment tasks in selectDashboard() to prevent stale background work (ReportsViewModel.swift) - Surface error message when loadDetail fails instead of leaving spinner visible forever (ReportsViewModel.swift) - Remove Swift-side enrichCache dictionary; Zig core now handles persistent caching, eliminating the unsynchronized concurrent dictionary access (ReportsCore.swift) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
Why
Architecture
Enrichment cache (`src/enrichcache.zig`)
Dashboard (`macos/Reports/Views/DashboardView.swift`)
Test plan
🤖 Generated with Claude Code