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