Commit 8629bbb211
Unsigned
Layout: unified · split
octosentry/SecurityEventListView.swift +16 −2
| @@ -9,7 +9,9 @@ import SwiftUI | ||
| 9 | 9 | struct SecurityEventListView: View { |
| 10 | 10 | var store: SecurityEventStore |
| 11 | 11 | var authStore: AuthStore |
| 12 | var isStandaloneWindow: Bool = false | |
| 12 | 13 | @State private var showingRepoManager = false |
| 14 | @Environment(\.openWindow) private var openWindow | |
| 13 | 15 | |
| 14 | 16 | var body: some View { |
| 15 | 17 | VStack(alignment: .leading, spacing: 0) { |
| @@ -23,7 +25,6 @@ struct SecurityEventListView: View { | ||
| 23 | 25 | content |
| 24 | 26 | } |
| 25 | 27 | } |
| 26 | .frame(width: 380, height: 420) | |
| 27 | 28 | .task(id: authStore.isSignedIn) { |
| 28 | 29 | guard authStore.isSignedIn else { return } |
| 29 | 30 | await store.refresh() |
| @@ -66,6 +67,16 @@ struct SecurityEventListView: View { | ||
| 66 | 67 | } |
| 67 | 68 | |
| 68 | 69 | if authStore.isSignedIn { |
| 70 | if !isStandaloneWindow { | |
| 71 | Button { | |
| 72 | openWindow(id: SecurityEventWindow.id) | |
| 73 | } label: { | |
| 74 | Image(systemName: "macwindow") | |
| 75 | } | |
| 76 | .buttonStyle(.plain) | |
| 77 | .help("Open in Window") | |
| 78 | } | |
| 79 | ||
| 69 | 80 | Button { |
| 70 | 81 | showingRepoManager.toggle() |
| 71 | 82 | } label: { |
| @@ -116,7 +127,9 @@ struct SecurityEventListView: View { | ||
| 116 | 127 | Divider() |
| 117 | 128 | } |
| 118 | 129 | ForEach(store.events) { event in |
| 119 | SecurityEventRow(event: event) | |
| 130 | SecurityEventRow(event: event) { | |
| 131 | Task { await store.markSeen(event.id) } | |
| 132 | } | |
| 120 | 133 | Divider() |
| 121 | 134 | } |
| 122 | 135 | } |
| @@ -250,4 +263,5 @@ private struct StatusView: View { | ||
| 250 | 263 | |
| 251 | 264 | #Preview { |
| 252 | 265 | SecurityEventListView(store: SecurityEventStore(), authStore: AuthStore()) |
| 266 | .frame(width: 380, height: 420) | |
| 253 | 267 | } |
octosentry/SecurityEventRow.swift +57 −42
| @@ -8,6 +8,7 @@ import SwiftUI | ||
| 8 | 8 | |
| 9 | 9 | struct SecurityEventRow: View { |
| 10 | 10 | let event: SecurityEvent |
| 11 | var onMarkSeen: () -> Void | |
| 11 | 12 | |
| 12 | 13 | private static let relativeFormatter: RelativeDateTimeFormatter = { |
| 13 | 14 | let formatter = RelativeDateTimeFormatter() |
| @@ -16,58 +17,72 @@ struct SecurityEventRow: View { | ||
| 16 | 17 | }() |
| 17 | 18 | |
| 18 | 19 | var body: some View { |
| 19 | Button { | |
| 20 | NSWorkspace.shared.open(event.detailURL) | |
| 21 | } label: { | |
| 22 | VStack(alignment: .leading, spacing: 3) { | |
| 23 | HStack(spacing: 6) { | |
| 24 | Text(event.nativeSeverityLabel.uppercased()) | |
| 25 | .font(.caption2.weight(.bold)) | |
| 26 | .foregroundStyle(event.severity.color) | |
| 27 | .padding(.horizontal, 6) | |
| 28 | .padding(.vertical, 2) | |
| 29 | .background(event.severity.color.opacity(0.18), in: Capsule()) | |
| 20 | HStack(alignment: .top, spacing: 0) { | |
| 21 | Button { | |
| 22 | NSWorkspace.shared.open(event.detailURL) | |
| 23 | } label: { | |
| 24 | VStack(alignment: .leading, spacing: 3) { | |
| 25 | HStack(spacing: 6) { | |
| 26 | Text(event.nativeSeverityLabel.uppercased()) | |
| 27 | .font(.caption2.weight(.bold)) | |
| 28 | .foregroundStyle(event.severity.color) | |
| 29 | .padding(.horizontal, 6) | |
| 30 | .padding(.vertical, 2) | |
| 31 | .background(event.severity.color.opacity(0.18), in: Capsule()) | |
| 30 | 32 | |
| 31 | Text(event.source.displayName) | |
| 32 | .font(.caption2.weight(.semibold)) | |
| 33 | .padding(.horizontal, 6) | |
| 34 | .padding(.vertical, 2) | |
| 35 | .background(.secondary.opacity(0.15), in: Capsule()) | |
| 33 | Text(event.source.displayName) | |
| 34 | .font(.caption2.weight(.semibold)) | |
| 35 | .padding(.horizontal, 6) | |
| 36 | .padding(.vertical, 2) | |
| 37 | .background(.secondary.opacity(0.15), in: Capsule()) | |
| 36 | 38 | |
| 37 | Text(event.repoFullName) | |
| 38 | .font(.caption) | |
| 39 | .foregroundStyle(.secondary) | |
| 39 | Text(event.repoFullName) | |
| 40 | .font(.caption) | |
| 41 | .foregroundStyle(.secondary) | |
| 40 | 42 | |
| 41 | Spacer() | |
| 43 | Spacer() | |
| 42 | 44 | |
| 43 | Text(Self.relativeFormatter.localizedString(for: event.createdAt, relativeTo: .now)) | |
| 44 | .font(.caption2) | |
| 45 | .foregroundStyle(.secondary) | |
| 45 | Text(Self.relativeFormatter.localizedString(for: event.createdAt, relativeTo: .now)) | |
| 46 | .font(.caption2) | |
| 47 | .foregroundStyle(.secondary) | |
| 48 | } | |
| 49 | ||
| 50 | Text(event.summary) | |
| 51 | .font(.callout) | |
| 52 | .lineLimit(1) | |
| 53 | .foregroundStyle(.primary) | |
| 46 | 54 | } |
| 55 | .padding(10) | |
| 56 | .contentShape(Rectangle()) | |
| 57 | } | |
| 58 | .buttonStyle(.plain) | |
| 47 | 59 | |
| 48 | Text(event.summary) | |
| 49 | .font(.callout) | |
| 50 | .lineLimit(1) | |
| 51 | .foregroundStyle(.primary) | |
| 60 | Button(action: onMarkSeen) { | |
| 61 | Image(systemName: "checkmark.circle") | |
| 52 | 62 | } |
| 53 | .padding(10) | |
| 54 | .contentShape(Rectangle()) | |
| 63 | .buttonStyle(.plain) | |
| 64 | .foregroundStyle(.secondary) | |
| 65 | .help("Mark as seen") | |
| 66 | .padding(.top, 12) | |
| 67 | .padding(.trailing, 10) | |
| 55 | 68 | } |
| 56 | .buttonStyle(.plain) | |
| 57 | 69 | } |
| 58 | 70 | } |
| 59 | 71 | |
| 60 | 72 | #Preview { |
| 61 | SecurityEventRow(event: SecurityEvent( | |
| 62 | id: "preview-1", | |
| 63 | source: .dependabot, | |
| 64 | repoFullName: "zerolabsco/octosentry", | |
| 65 | severity: .critical, | |
| 66 | nativeSeverityLabel: "Critical", | |
| 67 | summary: "Denial of service in some-package", | |
| 68 | detailURL: URL(string: "https://github.com")!, | |
| 69 | createdAt: .now.addingTimeInterval(-3600 * 26), | |
| 70 | updatedAt: .now, | |
| 71 | seenLocally: false | |
| 72 | )) | |
| 73 | SecurityEventRow( | |
| 74 | event: SecurityEvent( | |
| 75 | id: "preview-1", | |
| 76 | source: .dependabot, | |
| 77 | repoFullName: "zerolabsco/octosentry", | |
| 78 | severity: .critical, | |
| 79 | nativeSeverityLabel: "Critical", | |
| 80 | summary: "Denial of service in some-package", | |
| 81 | detailURL: URL(string: "https://github.com")!, | |
| 82 | createdAt: .now.addingTimeInterval(-3600 * 26), | |
| 83 | updatedAt: .now, | |
| 84 | seenLocally: false | |
| 85 | ), | |
| 86 | onMarkSeen: {} | |
| 87 | ) | |
| 73 | 88 | } |
octosentry/SecurityEventStore.swift +16
| @@ -28,6 +28,10 @@ final class SecurityEventStore { | ||
| 28 | 28 | private(set) var watchedRepos: [String] = [] |
| 29 | 29 | private(set) var watchListErrorMessage: String? |
| 30 | 30 | |
| 31 | var unseenCriticalCount: Int { | |
| 32 | rawEvents.filter { $0.severity == .critical && !$0.seenLocally }.count | |
| 33 | } | |
| 34 | ||
| 31 | 35 | private let persistenceStore = PersistenceStore() |
| 32 | 36 | private var rawEvents: [SecurityEvent] = [] |
| 33 | 37 | private var pollingTask: Task<Void, Never>? |
| @@ -130,6 +134,18 @@ final class SecurityEventStore { | ||
| 130 | 134 | await refresh() |
| 131 | 135 | } |
| 132 | 136 | |
| 137 | /// Local-only triage state (spec §11) — no API write, no scope beyond | |
| 138 | /// read needed. Removes the event from the active stream. | |
| 139 | func markSeen(_ eventID: String) async { | |
| 140 | var state = await persistenceStore.load() | |
| 141 | state.seenEventIDs.insert(eventID) | |
| 142 | await persistenceStore.save(state) | |
| 143 | ||
| 144 | rawEvents.removeAll { $0.id == eventID } | |
| 145 | totalFetchedCount = rawEvents.count | |
| 146 | applyMinimumSeverityFilter() | |
| 147 | } | |
| 148 | ||
| 133 | 149 | func removeRepo(_ repoFullName: String) async { |
| 134 | 150 | var state = await persistenceStore.load() |
| 135 | 151 | state.watchedRepos.removeAll { $0 == repoFullName } |
octosentry/octosentryApp.swift +32 −1
| @@ -7,15 +7,46 @@ | ||
| 7 | 7 | |
| 8 | 8 | import SwiftUI |
| 9 | 9 | |
| 10 | enum SecurityEventWindow { | |
| 11 | static let id = "security-events-window" | |
| 12 | } | |
| 13 | ||
| 10 | 14 | @main |
| 11 | 15 | struct octosentryApp: App { |
| 12 | 16 | @State private var store = SecurityEventStore() |
| 13 | 17 | @State private var authStore = AuthStore() |
| 14 | 18 | |
| 15 | 19 | var body: some Scene { |
| 16 | MenuBarExtra("OctoSentry", systemImage: "shield.lefthalf.filled") { | |
| 20 | MenuBarExtra { | |
| 17 | 21 | SecurityEventListView(store: store, authStore: authStore) |
| 22 | .frame(width: 380, height: 420) | |
| 23 | } label: { | |
| 24 | MenuBarIconView(criticalCount: store.unseenCriticalCount) | |
| 18 | 25 | } |
| 19 | 26 | .menuBarExtraStyle(.window) |
| 27 | ||
| 28 | Window("Security Events", id: SecurityEventWindow.id) { | |
| 29 | SecurityEventListView(store: store, authStore: authStore, isStandaloneWindow: true) | |
| 30 | .frame(minWidth: 420, minHeight: 480) | |
| 31 | } | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | private struct MenuBarIconView: View { | |
| 36 | let criticalCount: Int | |
| 37 | ||
| 38 | var body: some View { | |
| 39 | ZStack(alignment: .topTrailing) { | |
| 40 | Image(systemName: criticalCount > 0 ? "exclamationmark.shield.fill" : "shield.lefthalf.filled") | |
| 41 | ||
| 42 | if criticalCount > 0 { | |
| 43 | Text(criticalCount > 9 ? "9+" : "\(criticalCount)") | |
| 44 | .font(.system(size: 8, weight: .bold)) | |
| 45 | .foregroundStyle(.white) | |
| 46 | .padding(2) | |
| 47 | .background(Circle().fill(.red)) | |
| 48 | .offset(x: 8, y: -6) | |
| 49 | } | |
| 50 | } | |
| 20 | 51 | } |
| 21 | 52 | } |