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
12 changes: 9 additions & 3 deletions App/AppServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import ChangeCore
import Directory
import Foundation
import GamEngine
import Jobs
import Setup
import Stores
import Vault
Expand All @@ -16,6 +17,8 @@ struct AppServices {
let gam: (runner: GamRunner, runtimeDirectory: URL)?
/// The only path to a write (ChangeCore); none without gam.
let executor: Executor?
/// Every write run since launch, as the executor reports it: the jobs tray.
let jobs: JobBoard
/// The user page's writes, through the executor.
let userChanges: UserChanges
/// A selected person's groups, delegates, auto-reply and signature, read live.
Expand Down Expand Up @@ -97,13 +100,16 @@ struct AppServices {
extraEnvironment: [String: String] = [:]) -> AppServices {
let runner = gam.map { AuthenticatedRunner(runner: $0.runner, vault: vault, runtimeDirectory: $0.runtimeDirectory) }
let setup = SetupModel(vault: vault, runner: runner, gamgui: gamgui, setupFolderURL: setupFolderURL, lastDomain: lastDomain)
// The executor runs on the domain Setup connected, at its generation: a switch refuses old previews.
// The executor runs on the domain Setup connected, at its generation: a switch refuses old previews. Each run it
// makes is reported to the jobs tray, whose Stop asks it to stop after the step in flight.
let jobs = JobBoard()
let executor = runner.map { runner in
Executor(runner: runner, audit: AuditLog(url: auditURL),
currentActor: { @MainActor [weak setup] in setup?.connectedAdmin }, tenant: { @MainActor [weak setup] in
setup?.active.map { ($0, setup!.generation) }
}, extraEnvironment: extraEnvironment)
}, extraEnvironment: extraEnvironment, jobs: jobs.report)
}
if let executor { jobs.stopRun = { @Sendable id in await executor.stopAfterCurrentStep(id) } }
let directory = DirectoryStore(setup: setup, runner: runner)
let groupMembers = GroupMembers(setup: setup, runner: runner)
let groups = GroupStore(setup: setup, runner: runner)
Expand All @@ -113,7 +119,7 @@ struct AppServices {
phishingChanges.keepNobodyHas = { [weak setup, weak nobodyHas] domain, messageIDs, at in
nobodyHas?.record(domain: domain, messageIDs: messageIDs, by: setup?.connectedAdmin ?? "", at: at)
}
return AppServices(setup: setup, directory: directory, gam: gam, executor: executor,
return AppServices(setup: setup, directory: directory, gam: gam, executor: executor, jobs: jobs,
userChanges: UserChanges(executor: executor, directory: directory),
userAccess: UserAccess(setup: setup, runner: runner),
signatureChanges: SignatureChanges(executor: executor, setup: setup, directory: directory),
Expand Down
36 changes: 36 additions & 0 deletions App/GamGUIApp.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AppKit
import Directory
import GamEngine
import Jobs
import Setup
import SwiftUI

Expand Down Expand Up @@ -97,6 +98,28 @@ enum ColumnWidths {
struct ContentView: View {
let services: AppServices
@State private var screen: Screen? = Screen.initial
#if DEBUG
@State private var demoJobs = false

/// Two made-up runs for the tray's snapshot: an offboarding part done, and a pull from every mailbox that ended with two
/// mailboxes failed. Events as the executor reports them; nothing runs.
static func showDemoJobs(_ board: JobBoard) {
let offboarding = UUID(), pull = UUID()
board.handle(.started(job: pull, preview: UUID(), title: "Trash message for 9 addresses", total: 9))
for name in ["alice", "bob", "dana", "erin", "frank", "gwen", "hank"] {
board.handle(.step(job: pull, item: "Trash message: \(name)@example.com", outcome: .succeeded, detail: ""))
}
for name in ["ivy", "jack"] {
board.handle(.step(job: pull, item: "Trash message: \(name)@example.com", outcome: .failed,
detail: "Mail service not enabled for \(name)@example.com."))
}
board.handle(.finished(job: pull))
board.handle(.started(job: offboarding, preview: UUID(), title: "Offboarding carol@example.com", total: 12))
for step in ["Sign out", "Reset password", "Hide from directory", "Set vacation", "Remove group member"] {
board.handle(.step(job: offboarding, item: "\(step): carol@example.com", outcome: .succeeded, detail: ""))
}
}
#endif
/// The person Signatures opens on when another screen sends it one.
@State private var signaturesPerson: String?
/// The person Calendars opens on when Users sends it one.
Expand Down Expand Up @@ -205,6 +228,19 @@ struct ContentView: View {
.navigationTitle("Setup")
}
}
// The jobs tray, on every screen: each run the executor makes, and Stop.
.toolbar {
ToolbarItem(placement: .primaryAction) { JobsButton(board: services.jobs) }
}
#if DEBUG
// Snapshots (`SWIFTGAMGUI_TAB=jobs`): the tray over the screen, with made-up runs: nothing runs.
.sheet(isPresented: $demoJobs) { JobsTray(board: services.jobs).frame(width: 440, height: 520) }
.task {
guard ProcessInfo.processInfo.environment["SWIFTGAMGUI_TAB"] == "jobs", Spikes.isRequested else { return }
Self.showDemoJobs(services.jobs)
demoJobs = true
}
#endif
// Siri's request opens Users in the window in front, where it's drafted.
.onChange(of: services.siriDrafts.titleChange != nil && appearsActive, initial: true) { _, wanted in
if wanted { screen = .users }
Expand Down
142 changes: 142 additions & 0 deletions App/JobsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import Jobs
import SwiftUI

/// The jobs tray (GamGUI's plan U5) in every window's toolbar: how many runs are going, and on a click every run since launch,
/// running first, each with where it stands, its newest steps and its failures in words, and Stop. Reads the board only; Stop
/// asks the executor to stop after the step in flight, which always finishes and is audited (`JobBoard`).
struct JobsButton: View {
let board: JobBoard
@State private var open = false
@Environment(\.appearsActive) private var appearsActive

var body: some View {
let running = board.running
Button { open.toggle() } label: {
Label(running == 0 ? "Jobs" : "\(running) Running", systemImage: running == 0 ? "checklist" : "arrow.triangle.2.circlepath")
.labelStyle(.titleAndIcon)
}
.help(running == 0 ? "The changes run since GAM GUI started" : "\(running) change\(running == 1 ? " is" : "s are") running: see where each stands")
.accessibilityLabel(running == 0 ? "Jobs" : "Jobs: \(running) running")
.popover(isPresented: $open, arrowEdge: .bottom) {
JobsTray(board: board).frame(width: 440, height: 480)
}
// A run that ends is said from the window in front: by the board's counter, so a step redraws nothing here.
.onChange(of: board.finishedCount) {
if appearsActive, let job = board.lastFinished {
AccessibilityNotification.Announcement("Finished: \(job.title). \(job.status)").post()
}
}
}
}

/// The tray's list: at most `JobBoard.trayRows`, running first, each opening to its steps.
struct JobsTray: View {
let board: JobBoard
@State private var stopping: Job?

var body: some View {
let tray = board.tray
VStack(alignment: .leading, spacing: 0) {
Text("Jobs").font(.headline).accessibilityAddTraits(.isHeader).padding([.horizontal, .top])
if tray.rows.isEmpty {
ContentUnavailableView("Nothing Has Run", systemImage: "checklist",
description: Text("A change you confirm is listed here while it runs and after it ends."))
} else {
List(tray.rows) { job in
JobRow(job: job) { stopping = job }
}
.listStyle(.inset)
if tray.more > 0 {
Text("And \(tray.more) older not listed.").font(.callout).foregroundStyle(.secondary).padding(.horizontal)
}
}
Text("Every change is in Audit too. To try again what failed or didn't run, use the screen that ran it: its preview "
+ "shows what's left.")
.font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true).padding()
}
.confirmationDialog(stopping.map { "Stop \($0.title)?" } ?? "", isPresented: Binding(get: { stopping != nil }, set: { if !$0 { stopping = nil } }),
presenting: stopping) { job in
Button("Stop After This Step", role: .destructive) { Task { await board.stop(job.id) } }
Button("Keep Running", role: .cancel) {}
} message: { _ in
Text("The step running now finishes. The steps after it won't run: the run may be left part done, so check its screen.")
}
}
}

/// One run: its name, where it stands in words and a bar (never color alone), Stop while it runs, and its steps.
private struct JobRow: View {
let job: Job
let stop: () -> Void

private var symbol: String {
guard job.finished else { return "arrow.triangle.2.circlepath" }
return job.failedTotal > 0 ? "exclamationmark.triangle.fill" : job.notRun + job.waiting > 0 ? "pause.circle.fill" : "checkmark.circle.fill"
}

var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(job.title).fontWeight(.semibold)
Text(job.status).font(.callout).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: symbol).symbolRenderingMode(.multicolor)
}
.accessibilityElement(children: .combine)
Spacer()
if !job.finished {
Button("Stop…", action: stop)
.disabled(job.stopping)
.accessibilityLabel("Stop \(job.title)")
.accessibilityHint("Asks first. The step running finishes; the rest don't run.")
}
}
if !job.finished, job.total > 0 {
ProgressView(value: Double(job.done), total: Double(job.total))
.accessibilityLabel("\(job.done) of \(job.total) steps done")
}
if !job.recent.isEmpty || !job.failed.isEmpty {
DisclosureGroup("Steps") {
VStack(alignment: .leading, spacing: 4) {
if !job.failed.isEmpty {
Text("Failed").fontWeight(.semibold).accessibilityAddTraits(.isHeader)
ForEach(Array(job.failed.enumerated()), id: \.offset) { _, step in StepLine(step: step) }
if job.more > 0 { Text("And \(job.more) more.").foregroundStyle(.secondary) }
}
Text(job.done > job.recent.count ? "The newest \(job.recent.count)" : "Each step").fontWeight(.semibold)
.accessibilityAddTraits(.isHeader)
ForEach(Array(job.recent.enumerated()), id: \.offset) { _, step in StepLine(step: step) }
}
.font(.callout)
.textSelection(.enabled)
}
}
}
.padding(.vertical, 4)
}
}

/// A step: what it was, how it ended in words, and why when it didn't succeed.
private struct StepLine: View {
let step: Job.Step

private var ended: String {
switch step.outcome {
case .succeeded: "Done"
case .failed: "Failed"
case .notRun: "Not run"
case .waiting: "Waiting for Google"
}
}

var body: some View {
VStack(alignment: .leading, spacing: 1) {
Text("\(ended): \(step.item)")
if !step.detail.isEmpty { Text(step.detail).foregroundStyle(.secondary) }
}
.accessibilityElement(children: .combine)
}
}
4 changes: 2 additions & 2 deletions Packages/GamKit/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ let package = Package(
],
targets: [
.target(name: "GamEngine", dependencies: ["Vault"]),
.target(name: "ChangeCore", dependencies: ["GamEngine", "Vault"]),
.target(name: "ChangeCore", dependencies: ["GamEngine", "Vault", "Jobs"]),
.target(name: "Vault"),
.target(name: "Catalog"),
.target(name: "Jobs"),
Expand All @@ -32,7 +32,7 @@ let package = Package(
.target(name: "TestSupport", dependencies: ["GamEngine", "Vault"]),
.testTarget(name: "GamEngineTests", dependencies: ["GamEngine", "Vault", "TestSupport"]),
.testTarget(name: "VaultTests", dependencies: ["Vault"]),
.testTarget(name: "ChangeCoreTests", dependencies: ["ChangeCore", "GamEngine", "Vault", "TestSupport"]),
.testTarget(name: "ChangeCoreTests", dependencies: ["ChangeCore", "GamEngine", "Vault", "Jobs", "TestSupport"]),
.testTarget(name: "SetupTests", dependencies: ["Setup", "GamEngine", "Vault", "TestSupport"]),
.testTarget(name: "DirectoryStoreTests", dependencies: ["Directory", "Setup", "GamEngine", "Vault", "ChangeCore", "Stores", "TestSupport"]),
.testTarget(name: "StoresTests", dependencies: ["Stores", "GamEngine", "TestSupport"]),
Expand Down
Loading
Loading