Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions macos/Reports/Assets.xcassets/Logo.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "reports.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
32 changes: 32 additions & 0 deletions macos/Reports/Assets.xcassets/Logo.imageset/reports.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
115 changes: 113 additions & 2 deletions macos/Reports/Models.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,40 @@
import Foundation
import SwiftUI

// MARK: - Semantic Colors (neon palette matching logo rgb(194,255,38))

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).
Comment on lines +6 to +18

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
static let barText = Color(light: .white, dark: .black)
}

private extension Color {
init(light: Color, dark: Color) {
self.init(nsColor: NSColor(name: nil) { appearance in
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
? NSColor(dark)
: NSColor(light)
})
}
}

// MARK: - Policy color (green -> cyan -> blue -> magenta -> gray)

func policyColor(_ policy: String) -> Color {
switch policy.lowercased() {
case "reject", "sts":
return .green
return .passGreen
case "tlsa":
return .cyan
case "quarantine":
Expand Down Expand Up @@ -70,6 +98,7 @@ struct ReportEntry: Codable, Identifiable, Hashable {
let domain: String
let policy: String
let filename: String
let problems: Int

var id: String { "\(account)-\(type.rawValue)-\(reportId)" }

Expand All @@ -80,7 +109,7 @@ struct ReportEntry: Codable, Identifiable, Hashable {
enum CodingKeys: String, CodingKey {
case account, type, org
case reportId = "id"
case date, domain, policy, filename
case date, domain, policy, filename, problems
}
}

Expand Down Expand Up @@ -255,3 +284,85 @@ struct IpEnrichment: Codable {
return String(scalars.map { Character($0) })
}
}

// MARK: - Dashboard Stats (from reports_dashboard API)

struct KV: Codable {
let k: String
let v: UInt64
}

struct DashDomainAuth: Codable {
let domain: String
let dkim_pass: UInt64
let dkim_fail: UInt64
let spf_pass: UInt64
let spf_fail: UInt64
let disp_none: UInt64
let disp_quarantine: UInt64
let disp_reject: UInt64
}

struct DashDomainTls: Codable {
let domain: String
let success: UInt64
let failure: UInt64
let policy_types: [KV]
let failure_types: [KV]
}

struct DashboardJSON: Codable {
let dmarc_orgs: [KV]
let tlsrpt_orgs: [KV]
let dispositions: [KV]
let tls_policy_types: [KV]
let tls_failure_types: [KV]
let domain_auth: [DashDomainAuth]
let domain_tls: [DashDomainTls]
}

// MARK: - Mail Source (from reports_sources API)

struct MailSource: Codable, Identifiable {
let ip: String
let messages: UInt64
let dmarc_issues: UInt64
let tls_failures: UInt64
let types: [String]
let domains: [String]
let ptr: String
let asn: String
let asn_org: String
let country: String

var id: String { ip }
var totalIssues: UInt64 { dmarc_issues + tls_failures }
var hasIssues: Bool { totalIssues > 0 }

var hasDmarc: Bool { types.contains("dmarc") }
var hasTlsrpt: Bool { types.contains("tlsrpt") }

var ptrDisplay: String {
if ptr.isEmpty || ptr == ip { return "-" }
return ptr
}

var asnDisplay: String {
if asn.isEmpty { return "-" }
if asn_org.isEmpty { return "AS\(asn)" }
return "AS\(asn) \(asn_org)"
}

var countryFlag: String {
guard country.count >= 2 else { return "-" }
let base: UInt32 = 0x1F1E6
let upper = country.uppercased()
let scalars = upper.prefix(2).unicodeScalars.compactMap { scalar -> Unicode.Scalar? in
let offset = scalar.value - 0x41
guard offset < 26 else { return nil }
return Unicode.Scalar(base + offset)
}
guard scalars.count == 2 else { return "-" }
return String(scalars.map { Character($0) })
}
}
9 changes: 8 additions & 1 deletion macos/Reports/ReportsApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ struct ReportsApp: App {
.defaultSize(width: 1000, height: 700)
.commands {
CommandGroup(after: .newItem) {
Button("Fetch Reports") {
Button("Add Account...") {
viewModel.showAddAccount = true
}
.keyboardShortcut("n", modifiers: [.command, .shift])

Divider()

Button("Sync Reports") {
Task { await viewModel.fetchReports() }
}
.keyboardShortcut("r", modifiers: [.command])
Expand Down
100 changes: 99 additions & 1 deletion macos/Reports/ReportsCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,64 @@ final class ReportsCore: @unchecked Sendable {
}
}

/// Fetch reports for a specific account only.
func fetchAccount(_ name: String) async throws {
guard let config = configJSON() else {
throw ReportsError.noConfig
}
let errorMessage: String? = await Task.detached {
let errPtr = reports_fetch_account(config, name)
if let errPtr {
let msg = String(cString: errPtr)
reports_free_string(errPtr)
return msg
}
return nil
}.value
if let errorMessage {
throw ReportsError.fetchAccountFailed(errorMessage)
}
}

/// Enrich all source IPs with PTR/ASN/country.
func enrich() async throws {
guard let config = configJSON() else {
throw ReportsError.noConfig
}
let result = await Task.detached {
reports_enrich(config)
}.value
if result != 0 {
throw ReportsError.enrichFailed
}
}

/// Rebuild dashboard and mail sources caches.
func aggregate() async throws {
guard let config = configJSON() else {
throw ReportsError.noConfig
}
let result = await Task.detached {
reports_aggregate(config)
}.value
if result != 0 {
throw ReportsError.aggregateFailed
}
}

/// Sync: fetch + enrich + aggregate in one call.
func sync() async throws {
guard let config = configJSON() else {
throw ReportsError.noConfig
}
let result = await Task.detached {
reports_sync(config)
}.value
if result != 0 {
throw ReportsError.fetchFailed
}
}

/// List all stored reports.
func list() throws -> [ReportEntry] {
guard let config = configJSON() else {
Expand All @@ -62,6 +120,36 @@ final class ReportsCore: @unchecked Sendable {
defer { reports_free_string(ptr) }
return String(cString: ptr)
}
/// Aggregate dashboard statistics across all reports.
func dashboard() throws -> DashboardJSON {
guard let config = configJSON() else {
throw ReportsError.noConfig
}
guard let ptr = reports_dashboard(config) else {
throw ReportsError.dashboardFailed
}
defer { reports_free_string(ptr) }

let json = String(cString: ptr)
let data = Data(json.utf8)
return try JSONDecoder().decode(DashboardJSON.self, from: data)
}

/// Aggregate mail sources across all reports.
func sources() throws -> [MailSource] {
guard let config = configJSON() else {
throw ReportsError.noConfig
}
guard let ptr = reports_sources(config) else {
throw ReportsError.sourcesFailed
}
defer { reports_free_string(ptr) }

let json = String(cString: ptr)
let data = Data(json.utf8)
return try JSONDecoder().decode([MailSource].self, from: data)
}

/// Enrich an IP address with PTR, ASN, org, and country info.
/// Results are cached persistently in the Zig core (.enrich_cache.jsonl).
func enrichIP(_ ip: String) -> IpEnrichment? {
Expand All @@ -76,15 +164,25 @@ final class ReportsCore: @unchecked Sendable {
enum ReportsError: LocalizedError {
case noConfig
case fetchFailed
case fetchAccountFailed(String)
case listFailed
case showFailed
case enrichFailed
case aggregateFailed
case dashboardFailed
case sourcesFailed

var errorDescription: String? {
switch self {
case .noConfig: return "Configuration file not found at ~/.config/reports/config.json"
case .fetchFailed: return "Failed to fetch reports"
case .fetchFailed: return "Failed to fetch reports. Connection or authentication failed — check your host, username, and password."
case .fetchAccountFailed(let message): return message
case .listFailed: return "Failed to list reports"
case .showFailed: return "Failed to load report"
case .enrichFailed: return "Failed to enrich IPs"
case .aggregateFailed: return "Failed to aggregate"
case .dashboardFailed: return "Failed to aggregate dashboard statistics"
case .sourcesFailed: return "Failed to aggregate mail sources"
}
}
}
Loading
Loading