Add dashboard, mail sources, setup wizard, and split CLI commands#12
Conversation
…s C APIs - Split monolithic fetch into four commands: sync, fetch, enrich, aggregate - Add reports_dashboard API with JSON caching (.dashboard_cache.json) - Add reports_sources API with enrichment data and caching (.sources_cache.json) - Add reports_fetch_account API for single-account fetch with curl error messages - Add per-report problems count to list API output - Track curl error messages via global last_curl_error for better diagnostics - Return -1 from fetch when all accounts fail IMAP auth/connection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…oard improvements - Add neon color palette (passGreen, failRed, neonYellow, barText) with dark mode support - Add problems column to report list and problems filter to sidebar - Add Mail Sources view with cross-report IP analysis (PTR/ASN/country from cache) - Move dashboard stats computation from Swift to Zig C API (reports_dashboard) - Move mail sources computation from Swift to Zig C API (reports_sources) - Add per-domain disposition, policy type, and failure type charts to dashboard - Add pass/fail legends to all bar charts with color-matched text - Responsive 2-column layout for dashboard charts at 1600px+ width - Use orange/blue type badges for DMARC/TLS-RPT in report list - Replace system green/red with neon colors across all views - Pre-load mail sources in background on app launch Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Show setup wizard when no accounts are configured - 4-step flow: credentials, server settings, account name, sync progress - Auto-detect IMAP host and account name from email domain - Trim whitespace from all input fields before saving - Run fetch/enrich/aggregate for the new account with progress display - Show curl error messages on connection/auth failure with retry option - Remove failed account from config when going back to edit settings - Add account button in sidebar and menu bar (⇧⌘N) - Add Reports logo SVG to asset catalog for wizard branding Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Self-review cleanup: these were replaced by per-domain disposition and domainKVChart views but the old definitions were left behind. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move DMARC/TLS-RPT problem counting logic from lib.zig and main.zig into a new stats.zig module with pure functions that take JSON data directly. This makes the logic testable without file I/O dependencies. Tests cover: empty records, all-pass, mixed pass/fail, missing fields, invalid JSON, zero failures, and multi-policy summation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR expands the reports app/CLI by splitting the “fetch” workflow into discrete phases, adding new Zig C-ABI aggregation APIs with on-disk JSON caching for instant UI load, and introducing new macOS UI surfaces (dashboard enhancements, problems tracking, mail sources, and an account setup wizard).
Changes:
- Split the CLI workflow into
sync,fetch,enrich, andaggregate, plus add a per-account fetch API for the setup wizard. - Add new Zig C APIs (
reports_dashboard,reports_sources,reports_fetch_account) and cache files (.dashboard_cache.json,.sources_cache.json) to support fast macOS UI loading. - Update macOS UI with a setup wizard, problems filtering, a mail sources view, and refreshed dashboard charts/colors.
Reviewed changes
Copilot reviewed 19 out of 20 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| src/reports.h | Exposes new C-ABI functions for per-account fetch and dashboard/sources aggregation. |
| src/main.zig | Adds sync/enrich/aggregate commands and updates list JSON output to include problems. |
| src/lib.zig | Implements new exported C APIs, dashboard/sources cache builders, and “problems” computation in list output. |
| src/imap.zig | Adds a “last curl error” accessor intended for surfacing IMAP errors in the setup wizard. |
| macos/Reports/Views/SidebarView.swift | Adds Problems and Mail Sources navigation plus an Add Account button. |
| macos/Reports/Views/SetupWizardView.swift | New 4-step wizard for adding an account and running fetch/enrich/aggregate with progress + retry. |
| macos/Reports/Views/SettingsView.swift | Updates status indicator colors to semantic palette. |
| macos/Reports/Views/ReportListView.swift | Adds a Problems column and updates the navigation title for Problems filtering. |
| macos/Reports/Views/ReportDetailView.swift | Updates pass/fail styling to semantic palette colors. |
| macos/Reports/Views/MailSourcesView.swift | New table view for cross-report source IP analysis with enrichment fields. |
| macos/Reports/Views/DashboardView.swift | Switches dashboard aggregation to Zig API/caches; adds new per-domain charts and responsive layout. |
| macos/Reports/Views/ContentView.swift | Shows setup wizard when no accounts; adds Add Account sheet presentation; centralizes error alert. |
| macos/Reports/ReportsViewModel.swift | Adds problems filtering, mail sources loading, “hasAccounts” detection, and switches fetch flow to sync. |
| macos/Reports/ReportsCore.swift | Adds Swift wrappers for fetchAccount/enrich/aggregate/sync/dashboard/sources APIs and new error cases. |
| macos/Reports/ReportsApp.swift | Adds Add Account menu command and renames Fetch to Sync. |
| macos/Reports/Models.swift | Introduces semantic neon palette colors and adds models for dashboard + mail sources JSON. |
| macos/Reports/Assets.xcassets/Logo.imageset/reports.svg | Adds/updates vector logo asset. |
| macos/Reports/Assets.xcassets/Logo.imageset/Contents.json | Adds asset catalog entry for the SVG logo. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| while (it.next()) |kv| { | ||
| if (!first) buf.appendSlice(allocator, ",") catch return null; | ||
| first = false; | ||
| const dom = kv.key_ptr.*; | ||
|
|
||
| // Collect policy types for this domain | ||
| var pt_buf: std.ArrayList(u8) = .empty; | ||
| defer pt_buf.deinit(allocator); | ||
| pt_buf.appendSlice(allocator, "[") catch continue; | ||
| { | ||
| var pt_first = true; | ||
| var pt_it = domain_policy_types.iterator(); | ||
| while (pt_it.next()) |pt_kv| { | ||
| const compound = pt_kv.key_ptr.*; | ||
| if (std.mem.indexOfScalar(u8, compound, 0)) |sep| { | ||
| if (std.mem.eql(u8, compound[0..sep], dom)) { | ||
| if (!pt_first) pt_buf.appendSlice(allocator, ",") catch continue; | ||
| pt_first = false; | ||
| const entry = std.fmt.allocPrint(allocator, "{{\"k\":\"{s}\",\"v\":{d}}}", .{ compound[sep + 1 ..], pt_kv.value_ptr.* }) catch continue; | ||
| defer allocator.free(entry); | ||
| pt_buf.appendSlice(allocator, entry) catch continue; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| pt_buf.appendSlice(allocator, "]") catch continue; | ||
|
|
||
| // Collect failure types for this domain | ||
| var ft_buf: std.ArrayList(u8) = .empty; | ||
| defer ft_buf.deinit(allocator); | ||
| ft_buf.appendSlice(allocator, "[") catch continue; | ||
| { | ||
| var ft_first = true; | ||
| var ft_it = domain_failure_types.iterator(); | ||
| while (ft_it.next()) |ft_kv| { | ||
| const compound = ft_kv.key_ptr.*; | ||
| if (std.mem.indexOfScalar(u8, compound, 0)) |sep| { | ||
| if (std.mem.eql(u8, compound[0..sep], dom)) { | ||
| if (!ft_first) ft_buf.appendSlice(allocator, ",") catch continue; | ||
| ft_first = false; | ||
| const entry = std.fmt.allocPrint(allocator, "{{\"k\":\"{s}\",\"v\":{d}}}", .{ compound[sep + 1 ..], ft_kv.value_ptr.* }) catch continue; | ||
| defer allocator.free(entry); | ||
| ft_buf.appendSlice(allocator, entry) catch continue; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ft_buf.appendSlice(allocator, "]") catch continue; | ||
|
|
||
| const line = std.fmt.allocPrint(allocator, "{{\"domain\":\"{s}\",\"success\":{d},\"failure\":{d},\"policy_types\":{s},\"failure_types\":{s}}}", .{ | ||
| dom, kv.value_ptr.success, kv.value_ptr.failure, pt_buf.items, ft_buf.items, |
There was a problem hiding this comment.
The per-domain policy/failure-type rendering is O(domains × (all policy/failure keys)) because it iterates the entire domain_policy_types / domain_failure_types map for every domain. With many domains/types this can become a noticeable bottleneck during cache rebuild. Consider grouping keys by domain once (e.g., a StringHashMap([]KV) keyed by domain) to make this linear overall.
| while (it.next()) |kv| { | |
| if (!first) buf.appendSlice(allocator, ",") catch return null; | |
| first = false; | |
| const dom = kv.key_ptr.*; | |
| // Collect policy types for this domain | |
| var pt_buf: std.ArrayList(u8) = .empty; | |
| defer pt_buf.deinit(allocator); | |
| pt_buf.appendSlice(allocator, "[") catch continue; | |
| { | |
| var pt_first = true; | |
| var pt_it = domain_policy_types.iterator(); | |
| while (pt_it.next()) |pt_kv| { | |
| const compound = pt_kv.key_ptr.*; | |
| if (std.mem.indexOfScalar(u8, compound, 0)) |sep| { | |
| if (std.mem.eql(u8, compound[0..sep], dom)) { | |
| if (!pt_first) pt_buf.appendSlice(allocator, ",") catch continue; | |
| pt_first = false; | |
| const entry = std.fmt.allocPrint(allocator, "{{\"k\":\"{s}\",\"v\":{d}}}", .{ compound[sep + 1 ..], pt_kv.value_ptr.* }) catch continue; | |
| defer allocator.free(entry); | |
| pt_buf.appendSlice(allocator, entry) catch continue; | |
| } | |
| } | |
| } | |
| } | |
| pt_buf.appendSlice(allocator, "]") catch continue; | |
| // Collect failure types for this domain | |
| var ft_buf: std.ArrayList(u8) = .empty; | |
| defer ft_buf.deinit(allocator); | |
| ft_buf.appendSlice(allocator, "[") catch continue; | |
| { | |
| var ft_first = true; | |
| var ft_it = domain_failure_types.iterator(); | |
| while (ft_it.next()) |ft_kv| { | |
| const compound = ft_kv.key_ptr.*; | |
| if (std.mem.indexOfScalar(u8, compound, 0)) |sep| { | |
| if (std.mem.eql(u8, compound[0..sep], dom)) { | |
| if (!ft_first) ft_buf.appendSlice(allocator, ",") catch continue; | |
| ft_first = false; | |
| const entry = std.fmt.allocPrint(allocator, "{{\"k\":\"{s}\",\"v\":{d}}}", .{ compound[sep + 1 ..], ft_kv.value_ptr.* }) catch continue; | |
| defer allocator.free(entry); | |
| ft_buf.appendSlice(allocator, entry) catch continue; | |
| } | |
| } | |
| } | |
| } | |
| ft_buf.appendSlice(allocator, "]") catch continue; | |
| const line = std.fmt.allocPrint(allocator, "{{\"domain\":\"{s}\",\"success\":{d},\"failure\":{d},\"policy_types\":{s},\"failure_types\":{s}}}", .{ | |
| dom, kv.value_ptr.success, kv.value_ptr.failure, pt_buf.items, ft_buf.items, | |
| var policy_types_by_domain = std.StringHashMap(std.ArrayList(u8)).init(allocator); | |
| defer { | |
| var grouped_it = policy_types_by_domain.iterator(); | |
| while (grouped_it.next()) |entry| { | |
| entry.value_ptr.deinit(allocator); | |
| } | |
| policy_types_by_domain.deinit(); | |
| } | |
| { | |
| var pt_it = domain_policy_types.iterator(); | |
| while (pt_it.next()) |pt_kv| { | |
| const compound = pt_kv.key_ptr.*; | |
| const sep = std.mem.indexOfScalar(u8, compound, 0) orelse continue; | |
| const dom = compound[0..sep]; | |
| const type_name = compound[sep + 1 ..]; | |
| const gop = policy_types_by_domain.getOrPut(dom) catch continue; | |
| if (!gop.found_existing) { | |
| gop.value_ptr.* = .empty; | |
| gop.value_ptr.appendSlice(allocator, "[") catch { | |
| _ = policy_types_by_domain.remove(dom); | |
| continue; | |
| }; | |
| } | |
| if (gop.value_ptr.items.len > 1) gop.value_ptr.appendSlice(allocator, ",") catch continue; | |
| const entry = std.fmt.allocPrint(allocator, "{{\"k\":\"{s}\",\"v\":{d}}}", .{ type_name, pt_kv.value_ptr.* }) catch continue; | |
| defer allocator.free(entry); | |
| gop.value_ptr.appendSlice(allocator, entry) catch continue; | |
| } | |
| var grouped_it = policy_types_by_domain.iterator(); | |
| while (grouped_it.next()) |entry| { | |
| entry.value_ptr.appendSlice(allocator, "]") catch continue; | |
| } | |
| } | |
| var failure_types_by_domain = std.StringHashMap(std.ArrayList(u8)).init(allocator); | |
| defer { | |
| var grouped_it = failure_types_by_domain.iterator(); | |
| while (grouped_it.next()) |entry| { | |
| entry.value_ptr.deinit(allocator); | |
| } | |
| failure_types_by_domain.deinit(); | |
| } | |
| { | |
| var ft_it = domain_failure_types.iterator(); | |
| while (ft_it.next()) |ft_kv| { | |
| const compound = ft_kv.key_ptr.*; | |
| const sep = std.mem.indexOfScalar(u8, compound, 0) orelse continue; | |
| const dom = compound[0..sep]; | |
| const type_name = compound[sep + 1 ..]; | |
| const gop = failure_types_by_domain.getOrPut(dom) catch continue; | |
| if (!gop.found_existing) { | |
| gop.value_ptr.* = .empty; | |
| gop.value_ptr.appendSlice(allocator, "[") catch { | |
| _ = failure_types_by_domain.remove(dom); | |
| continue; | |
| }; | |
| } | |
| if (gop.value_ptr.items.len > 1) gop.value_ptr.appendSlice(allocator, ",") catch continue; | |
| const entry = std.fmt.allocPrint(allocator, "{{\"k\":\"{s}\",\"v\":{d}}}", .{ type_name, ft_kv.value_ptr.* }) catch continue; | |
| defer allocator.free(entry); | |
| gop.value_ptr.appendSlice(allocator, entry) catch continue; | |
| } | |
| var grouped_it = failure_types_by_domain.iterator(); | |
| while (grouped_it.next()) |entry| { | |
| entry.value_ptr.appendSlice(allocator, "]") catch continue; | |
| } | |
| } | |
| while (it.next()) |kv| { | |
| if (!first) buf.appendSlice(allocator, ",") catch return null; | |
| first = false; | |
| const dom = kv.key_ptr.*; | |
| const pt_json = if (policy_types_by_domain.get(dom)) |pt| pt.items else "[]"; | |
| const ft_json = if (failure_types_by_domain.get(dom)) |ft| ft.items else "[]"; | |
| const line = std.fmt.allocPrint(allocator, "{{\"domain\":\"{s}\",\"success\":{d},\"failure\":{d},\"policy_types\":{s},\"failure_types\":{s}}}", .{ | |
| dom, kv.value_ptr.success, kv.value_ptr.failure, pt_json, ft_json, |
| .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { | ||
| Button("OK") { viewModel.errorMessage = nil } | ||
| } message: { | ||
| Text(viewModel.errorMessage ?? "") | ||
| } |
There was a problem hiding this comment.
Using .alert(..., isPresented: .constant(viewModel.errorMessage != nil)) creates a non-settable binding. If the user dismisses the alert via Escape/clicking outside, SwiftUI will try to set the binding to false and it will be ignored, potentially causing the alert to reappear because errorMessage is unchanged. Prefer a binding that clears errorMessage in its setter (or a dedicated @Published var showError).
| // Append new account to existing accounts array | ||
| var accounts = (config["accounts"] as? [[String: Any]]) ?? [] | ||
| accounts.append(newAccount) | ||
| config["accounts"] = accounts | ||
|
|
||
| let data = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys]) | ||
| try data.write(to: configPath) | ||
| } catch { | ||
| errorMessage = error.localizedDescription | ||
| isSaving = false | ||
| return | ||
| } | ||
|
|
||
| withAnimation { step = 3 } | ||
| startSyncTask() | ||
| } | ||
|
|
||
| private func removeAccountFromConfig() { | ||
| let configPath = FileManager.default.homeDirectoryForCurrentUser | ||
| .appendingPathComponent(".config/reports/config.json") | ||
| guard let data = try? Data(contentsOf: configPath), | ||
| var config = try? JSONSerialization.jsonObject(with: data) as? [String: Any], | ||
| var accounts = config["accounts"] as? [[String: Any]] else { return } | ||
|
|
||
| accounts.removeAll { ($0["name"] as? String) == accountName } | ||
| config["accounts"] = accounts | ||
|
|
||
| if let newData = try? JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys]) { | ||
| try? newData.write(to: configPath) | ||
| } |
There was a problem hiding this comment.
When saving a new account, the wizard always appends to the existing accounts array and rollback removes all accounts matching accountName. If a user picks a name that already exists, this can create duplicates and the rollback path can delete the pre-existing account(s). Consider validating uniqueness up front (disable/inline error), or replacing an existing account with the same name, and making rollback target only the newly-added entry.
| extension Color { | ||
| /// Neon yellow for pass/success — matches logo rgb(194,255,38). | ||
| static let passGreen = Color(light: .init(red: 0.50, green: 0.66, blue: 0.10), | ||
| dark: .init(red: 0.76, green: 1.00, blue: 0.15)) | ||
| /// Neon pink for fail — electric magenta-pink. | ||
| static let failRed = Color(red: 1.00, green: 0.20, blue: 0.60) | ||
| /// Problem badge — neon pink-red, slightly muted for badge use. | ||
| static let problemRed = Color(light: .init(red: 0.70, green: 0.10, blue: 0.15), | ||
| dark: .init(red: 0.95, green: 0.30, blue: 0.35)) | ||
| /// Neon yellow matching the logo rgb(194,255,38) — for donut charts and accents. | ||
| static let neonYellow = Color(light: .init(red: 0.50, green: 0.66, blue: 0.10), | ||
| dark: .init(red: 0.76, green: 1.00, blue: 0.15)) | ||
| /// Bar chart overlay text — white on light (dark bars), black on dark (bright neon bars). |
There was a problem hiding this comment.
passGreen is documented as “Neon yellow” and uses yellow-ish RGB values, so the name is misleading (and there’s also a separate neonYellow with identical values). Consider renaming to reflect the actual semantic/color (e.g., passYellow or reusing neonYellow), to avoid confusion when applying colors elsewhere.
| const line = std.fmt.allocPrint(allocator, "{{\"ip\":\"{s}\",\"messages\":{d},\"dmarc_issues\":{d},\"tls_failures\":{d},\"types\":{s},\"domains\":{s},\"ptr\":\"{s}\",\"asn\":\"{s}\",\"asn_org\":\"{s}\",\"country\":\"{s}\"}}", .{ | ||
| kv.key_ptr.*, | ||
| kv.value_ptr.messages, | ||
| kv.value_ptr.dmarc_issues, | ||
| kv.value_ptr.tls_failures, |
There was a problem hiding this comment.
buildSourcesJson interpolates ptr_str / asn_org_str / etc directly into a JSON string via {s} without escaping. These fields can contain quotes/backslashes (see src/ipinfo.zig test "toJson escapes special characters"), which will produce invalid JSON and break Swift decoding. Please JSON-escape these strings (e.g., std.json.stringify / std.json.escapeString) or reuse the existing IpInfo.toJson escaping logic when embedding enrichment fields.
| _ = reports_enrich(config_json); | ||
| _ = reports_aggregate(config_json); |
There was a problem hiding this comment.
reports_sync ignores the return codes from reports_enrich and reports_aggregate, but still returns 0. That means sync can report success even when enrichment/aggregation fails. Please check each phase and return -1 if any step fails (or return distinct error codes if you want finer-grained UI errors).
| _ = reports_enrich(config_json); | |
| _ = reports_aggregate(config_json); | |
| if (reports_enrich(config_json) != 0) return -1; | |
| if (reports_aggregate(config_json) != 0) return -1; |
| fn cmdAggregate(allocator: std.mem.Allocator) !void { | ||
| const cfg = try Config.load(allocator); | ||
| defer cfg.deinit(allocator); | ||
|
|
||
| const names = cfg.accountNames(allocator) catch return; | ||
| defer allocator.free(names); | ||
|
|
||
| const ips = reports.fetch.collectSourceIps(allocator, cfg.data_dir, names) catch return; | ||
| defer reports.fetch.freeIpList(allocator, ips); | ||
|
|
||
| const entries = reports.store.listAllReports(allocator, cfg.data_dir, names) catch return; | ||
| defer reports.store.freeReportEntries(allocator, entries); | ||
|
|
||
| // Delete old caches so they are rebuilt on next access | ||
| for ([_][]const u8{ ".sources_cache.json", ".dashboard_cache.json" }) |filename| { | ||
| const path = std.fs.path.join(allocator, &.{ cfg.data_dir, filename }) catch continue; | ||
| defer allocator.free(path); | ||
| std.fs.deleteFileAbsolute(path) catch {}; | ||
| } |
There was a problem hiding this comment.
cmdAggregate is described as rebuilding the dashboard and mail sources caches, but it only deletes the cache files and prints counts; it never actually regenerates the caches. This makes reports aggregate a no-op until something later triggers lazy rebuild. Consider invoking the same builders used by the C API (or directly calling the dashboard/sources aggregation functions) so the command does what the usage text promises.
| const prefix = std.fmt.allocPrint(allocator, "\"{s}\":[", .{name}) catch return; | ||
| defer allocator.free(prefix); | ||
| buf.appendSlice(allocator, prefix) catch return; | ||
| var first = true; | ||
| var it = map.iterator(); | ||
| while (it.next()) |kv| { | ||
| if (!first) buf.appendSlice(allocator, ",") catch return; | ||
| first = false; | ||
| const line = std.fmt.allocPrint(allocator, "{{\"k\":\"{s}\",\"v\":{d}}}", .{ kv.key_ptr.*, kv.value_ptr.* }) catch continue; | ||
| defer allocator.free(line); | ||
| buf.appendSlice(allocator, line) catch return; | ||
| } | ||
| buf.appendSlice(allocator, "]") catch return; |
There was a problem hiding this comment.
writeMapArray silently returns/continues on allocation/append failures, which can leave the surrounding JSON buffer in a malformed state (e.g., commas appended without a subsequent entry). Since this output is parsed by Swift, it’s safer to propagate an error up (return !void / ?void) and abort JSON construction on failure rather than emitting partial/invalid JSON.
| const prefix = std.fmt.allocPrint(allocator, "\"{s}\":[", .{name}) catch return; | |
| defer allocator.free(prefix); | |
| buf.appendSlice(allocator, prefix) catch return; | |
| var first = true; | |
| var it = map.iterator(); | |
| while (it.next()) |kv| { | |
| if (!first) buf.appendSlice(allocator, ",") catch return; | |
| first = false; | |
| const line = std.fmt.allocPrint(allocator, "{{\"k\":\"{s}\",\"v\":{d}}}", .{ kv.key_ptr.*, kv.value_ptr.* }) catch continue; | |
| defer allocator.free(line); | |
| buf.appendSlice(allocator, line) catch return; | |
| } | |
| buf.appendSlice(allocator, "]") catch return; | |
| var tmp = std.ArrayList(u8){}; | |
| defer tmp.deinit(allocator); | |
| const prefix = std.fmt.allocPrint(allocator, "\"{s}\":[", .{name}) catch return; | |
| defer allocator.free(prefix); | |
| tmp.appendSlice(allocator, prefix) catch return; | |
| var first = true; | |
| var it = map.iterator(); | |
| while (it.next()) |kv| { | |
| if (!first) tmp.appendSlice(allocator, ",") catch return; | |
| first = false; | |
| const line = std.fmt.allocPrint( | |
| allocator, | |
| "{{\"k\":\"{s}\",\"v\":{d}}}", | |
| .{ kv.key_ptr.*, kv.value_ptr.* }, | |
| ) catch return; | |
| defer allocator.free(line); | |
| tmp.appendSlice(allocator, line) catch return; | |
| } | |
| tmp.appendSlice(allocator, "]") catch return; | |
| buf.appendSlice(allocator, tmp.items) catch return; |
| /// Last curl error message (static string from curl_easy_strerror, no need to free). | ||
| var g_last_curl_error: ?[]const u8 = null; | ||
|
|
||
| pub fn lastCurlError() ?[]const u8 { | ||
| return g_last_curl_error; | ||
| } |
There was a problem hiding this comment.
g_last_curl_error is a mutable global that gets written from Client.doPerform. IMAP fetch runs in multiple threads (see src/fetch.zig worker pool), so this introduces a data race and undefined behavior. Consider moving the error message to per-Client state, making it threadlocal, or protecting it with a mutex (and resetting it at the start of each request) so concurrent fetches can't clobber it.
| _ = buildAndCacheDashboard(cfg.data_dir, names); | ||
| _ = buildAndCacheSourcesWithNames(cfg.data_dir, names); |
There was a problem hiding this comment.
reports_aggregate always returns 0 even if buildAndCacheDashboard / buildAndCacheSourcesWithNames fails (both return nullable pointers). Since Swift treats a non-zero result as failure, this currently masks aggregation errors. Please propagate failures (e.g., return -1 when either builder returns null) so callers can surface errors correctly.
| _ = buildAndCacheDashboard(cfg.data_dir, names); | |
| _ = buildAndCacheSourcesWithNames(cfg.data_dir, names); | |
| if (buildAndCacheDashboard(cfg.data_dir, names) == null) return -1; | |
| if (buildAndCacheSourcesWithNames(cfg.data_dir, names) == null) return -1; |
Move DMARC/TLS-RPT aggregation functions from lib.zig into stats.zig: - aggregateDmarcReport: accumulates auth stats and dispositions from JSON - aggregateTlsReport: accumulates TLS sessions, policy/failure types from JSON - hashIncOwned/hashIncOwnedPrealloc: owned-key hash map incrementers - freeMapKeys: cleanup helper - DashAgg, TlsDomAgg, DmarcDashJson, TlsDashJson: shared types All functions are pure (no file I/O) and take allocator + JSON data directly. lib.zig now delegates to stats module, removing ~100 lines of inline logic. New tests: DMARC auth/disposition aggregation, TLS session/policy/failure aggregation, fallback domain handling, invalid JSON resilience, hashIncOwned increment behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests for the most critical behavior: multiple reports aggregating into the same domain counters, separate domains staying independent, multiple policy/failure types per domain, compound key correctness, and empty string edge case. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- JSON-escape enrichment strings (ptr, asn_org) in sources cache to prevent invalid JSON when fields contain quotes or backslashes - Propagate error codes from enrich/aggregate in reports_sync - Propagate null returns from cache builders in reports_aggregate - Make g_last_curl_error threadlocal to prevent data races in parallel fetch - Clarify cmdAggregate as cache invalidation (CLI cannot call lib.zig exports) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
fetchintosync,fetch,enrich,aggregatefor single-responsibility and independent executionreports_dashboard,reports_sources,reports_fetch_accountAPIs with JSON file caching (.dashboard_cache.json,.sources_cache.json) for instant UI loadingrgb(194,255,38), with dark mode supportTest plan
reports syncand verify fetch → enrich → aggregate executes in sequencereports fetch,reports enrich,reports aggregateindependently🤖 Generated with Claude Code