319 lines
12 KiB
Swift
319 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
typealias WorkflowRun = Components.Schemas.WorkflowRun
|
|
typealias RunStage = Components.Schemas.WorkflowRunStage
|
|
typealias StageAttempt = Components.Schemas.StageAttempt
|
|
|
|
private let stateColors: [RunStage.StatePayload: NodeColors] = [
|
|
.waiting: NodeColors(fill: Palette.surface, stroke: Palette.border, text: Palette.muted),
|
|
.running: NodeColors(fill: Palette.accentTint, stroke: Palette.accent, text: Palette.text),
|
|
.idle: NodeColors(fill: Palette.warnTint, stroke: Palette.warn, text: Palette.text),
|
|
.finished: NodeColors(fill: Palette.okTint, stroke: Palette.ok, text: Palette.text),
|
|
.merged: NodeColors(fill: Palette.okTint, stroke: Palette.ok, text: Palette.text),
|
|
.cleanedUp: NodeColors(fill: Palette.okTint, stroke: Palette.ok, text: Palette.text),
|
|
.discarded: NodeColors(fill: Palette.dangerTint, stroke: Palette.danger, text: Palette.text),
|
|
]
|
|
|
|
private func stageGraph(_ run: WorkflowRun) -> Flowchart {
|
|
var chart = Flowchart(direction: .td)
|
|
for (i, s) in run.stages.enumerated() {
|
|
let id = "s\(i)"
|
|
chart.node(id, label: s.name, shape: .box)
|
|
chart.nodes[id]!.style = stateColors[s.state]!
|
|
}
|
|
let ids = Dictionary(run.stages.enumerated().map { ($0.element.name, "s\($0.offset)") }, uniquingKeysWith: { a, _ in a })
|
|
for s in run.stages {
|
|
for a in s.after {
|
|
if let from = ids[a], let to = ids[s.name] {
|
|
chart.edges.append(FlowEdge(from: from, to: to, label: nil, style: .solid, arrow: true))
|
|
}
|
|
}
|
|
}
|
|
return chart
|
|
}
|
|
|
|
private func attemptLabel(_ a: StageAttempt) -> String {
|
|
switch a.state {
|
|
case .running: "running"
|
|
case .idle: "idle, not finished"
|
|
case .finished: "finished"
|
|
case .merged: "merged"
|
|
case .discarded: "discarded before finishing\(a.discardedBy.map { " by \($0)" } ?? "")"
|
|
case .cleanedUp: a.discardedBy == "byre" ? "finished, then discarded by the run's cleanup" : "finished, then discarded\(a.discardedBy.map { " by \($0)" } ?? "")"
|
|
}
|
|
}
|
|
|
|
private func stageLabel(_ s: RunStage) -> String {
|
|
if s.state != .waiting {
|
|
return attemptLabel(s.attempts.last { $0.output != nil } ?? s.attempts.last!)
|
|
}
|
|
if !s.waitingOn.isEmpty {
|
|
return "waiting on \(s.waitingOn.map { "“\($0)”" }.joined(separator: ", "))"
|
|
}
|
|
return "waiting to start"
|
|
}
|
|
|
|
private func stateColor(_ state: String) -> Color {
|
|
switch state {
|
|
case "running": Palette.accent
|
|
case "idle", "discarding": Palette.warn
|
|
case "finished", "merged": Palette.ok
|
|
default: Palette.muted
|
|
}
|
|
}
|
|
|
|
struct WorkflowView: View {
|
|
@Environment(Store.self) private var store
|
|
@ViewState private var run: WorkflowRun?
|
|
@ViewState private var error = ""
|
|
@ViewState private var target = 0
|
|
@ViewState private var loading = false
|
|
@ViewState private var again = false
|
|
|
|
var body: some View {
|
|
let task = store.selectedTaskId.flatMap { store.findTask($0) }
|
|
VStack(spacing: 0) {
|
|
if let task, let runId = task.workflowRun {
|
|
if !error.isEmpty {
|
|
EmptyText(text: error, error: true)
|
|
} else if let run, run.run == runId {
|
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
|
Text(run.name).font(Fonts.font(13, weight: 700))
|
|
Text(run.title).foregroundStyle(Palette.muted)
|
|
Text(run.finished ? "workflow finished" : "in progress")
|
|
.font(Fonts.font(11))
|
|
.foregroundStyle(run.finished ? Palette.ok : Palette.accent)
|
|
}
|
|
.modifier(HeaderBar())
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
FlowchartCanvas(layout: layoutFlowchart(stageGraph(run)))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
if !trimmed(run.brief).isEmpty {
|
|
DetailsBox(boxed: false) {
|
|
Text("Brief").foregroundStyle(Palette.muted)
|
|
} content: {
|
|
MarkdownView(blocks: parseMarkdown(run.brief), ctx: MDContext(taskId: task.id))
|
|
.padding(.top, 6)
|
|
}
|
|
}
|
|
ForEach(run.stages, id: \.name) { s in
|
|
StageCard(run: run, stage: s)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 16)
|
|
}
|
|
} else {
|
|
EmptyText(text: "Loading…")
|
|
}
|
|
} else {
|
|
EmptyText(text: task != nil ? "This task isn't part of a workflow run." : "No task selected.")
|
|
}
|
|
}
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
.task(id: store.selectedTaskId) {
|
|
target += 1
|
|
run = nil
|
|
error = ""
|
|
await load()
|
|
let off = store.events.add { ev in
|
|
if relevant(ev) {
|
|
Task {
|
|
await load()
|
|
}
|
|
}
|
|
}
|
|
await untilCancelled()
|
|
off()
|
|
target += 1
|
|
}
|
|
.onChange(of: task?.workflowRun) {
|
|
if run == nil && !loading && error.isEmpty {
|
|
Task {
|
|
await load()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func relevant(_ ev: ByreEvent) -> Bool {
|
|
guard ev.seq != 0, let run else {
|
|
return false
|
|
}
|
|
if ev.kind == "task.created" {
|
|
return ev.payload.value["workflow_run"] as? String == run.run
|
|
}
|
|
let ids = Set(run.stages.flatMap { $0.attempts.map(\.taskId) })
|
|
guard let taskId = ev.taskId else {
|
|
return false
|
|
}
|
|
return ids.contains(taskId) && (ev.kind.hasPrefix("task.") || ev.kind.hasPrefix("session."))
|
|
}
|
|
|
|
private func load() async {
|
|
guard let taskId = store.selectedTaskId, store.findTask(taskId)?.workflowRun != nil else {
|
|
return
|
|
}
|
|
if loading {
|
|
again = true
|
|
return
|
|
}
|
|
loading = true
|
|
target += 1
|
|
let started = target
|
|
do {
|
|
let fresh = try await store.api.client.getWorkflowRun(path: .init(taskId: taskId)).ok.body.json
|
|
if started == target {
|
|
run = fresh
|
|
error = ""
|
|
}
|
|
} catch {
|
|
if started == target {
|
|
self.error = errorText(error)
|
|
}
|
|
}
|
|
loading = false
|
|
if again {
|
|
again = false
|
|
await load()
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct StageCard: View {
|
|
let run: WorkflowRun
|
|
let stage: RunStage
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
|
Text(stage.name).font(Fonts.font(13, weight: 700))
|
|
Text(stageLabel(stage))
|
|
.font(Fonts.font(11))
|
|
.foregroundStyle(stateColor(stage.state.rawValue))
|
|
}
|
|
Text(stage.after.isEmpty ? "starts from the brief" : "after \(stage.after.map { "“\($0)”" }.joined(separator: ", "))")
|
|
.font(Fonts.font(11))
|
|
.foregroundStyle(Palette.muted)
|
|
if !stage.attempts.isEmpty {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
ForEach(Array(stage.attempts.enumerated()), id: \.offset) { i, a in
|
|
AttemptRow(run: run, stage: stage, attempt: a, index: i)
|
|
}
|
|
}
|
|
.padding(.top, 8)
|
|
}
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 10))
|
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border))
|
|
}
|
|
}
|
|
|
|
private struct AttemptRow: View {
|
|
let run: WorkflowRun
|
|
let stage: RunStage
|
|
let attempt: StageAttempt
|
|
let index: Int
|
|
@Environment(Store.self) private var store
|
|
|
|
var body: some View {
|
|
let a = attempt
|
|
let fed = run.stages.flatMap { s in s.attempts.filter { d in d.inputs.contains { $0.taskId == a.taskId } }.map(\.taskId) }
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
|
if stage.attempts.count > 1 {
|
|
Text("#\(index + 1)").foregroundStyle(Palette.muted)
|
|
}
|
|
Text(link(a.taskId, a.title, bold: a.taskId == store.selectedTaskId))
|
|
Text(attemptLabel(a))
|
|
.font(Fonts.font(11))
|
|
.foregroundStyle(stateColor(a.state.rawValue))
|
|
if a.discarding {
|
|
Text("discarding…").font(Fonts.font(11)).foregroundStyle(Palette.warn)
|
|
}
|
|
}
|
|
if let from = a.forkedFrom {
|
|
line([("fork of ", nil), (attemptName(from), from)])
|
|
}
|
|
if !a.inputs.isEmpty {
|
|
line([("started from ", nil)] + joined(a.inputs.map(\.taskId)))
|
|
}
|
|
if !fed.isEmpty {
|
|
line([("fed ", nil)] + joined(fed))
|
|
}
|
|
if let output = a.output {
|
|
DetailsBox(boxed: false) {
|
|
HStack(spacing: 0) {
|
|
Text("finish_stage output").foregroundStyle(Palette.muted)
|
|
if let commit = a.commit {
|
|
Text(" \(String(commit.prefix(10)))").foregroundStyle(Palette.muted)
|
|
}
|
|
}
|
|
} content: {
|
|
MarkdownView(blocks: parseMarkdown(output), ctx: MDContext(taskId: a.taskId))
|
|
.padding(.leading, 10)
|
|
.overlay(alignment: .leading) {
|
|
Rectangle().fill(Palette.border).frame(width: 1)
|
|
}
|
|
.padding(.top, 6)
|
|
}
|
|
.padding(.top, 6)
|
|
}
|
|
}
|
|
.padding(.top, 8)
|
|
.overlay(alignment: .top) {
|
|
Rectangle().fill(Palette.border).frame(height: 1)
|
|
}
|
|
}
|
|
|
|
private func joined(_ ids: [String]) -> [(String, String?)] {
|
|
var out: [(String, String?)] = []
|
|
for (n, id) in ids.enumerated() {
|
|
if n > 0 {
|
|
out.append((", ", nil))
|
|
}
|
|
out.append((attemptName(id), id))
|
|
}
|
|
return out
|
|
}
|
|
|
|
private func attemptName(_ taskId: String) -> String {
|
|
for s in run.stages {
|
|
if let i = s.attempts.firstIndex(where: { $0.taskId == taskId }) {
|
|
return s.attempts.count > 1 ? "“\(s.name)” attempt \(i + 1)" : "“\(s.name)”"
|
|
}
|
|
}
|
|
return store.findTask(taskId)?.title ?? taskId
|
|
}
|
|
|
|
private func link(_ taskId: String, _ label: String, bold: Bool = false) -> AttributedString {
|
|
var a = AttributedString(label)
|
|
a.link = AppLink.task(taskId)
|
|
a.foregroundColor = Palette.accent
|
|
a.font = Fonts.font(13, weight: bold ? 700 : 400)
|
|
return a
|
|
}
|
|
|
|
private func line(_ parts: [(String, String?)]) -> some View {
|
|
var out = AttributedString()
|
|
for (text, taskId) in parts {
|
|
if let taskId {
|
|
var a = link(taskId, text)
|
|
a.font = Fonts.font(11)
|
|
out += a
|
|
} else {
|
|
out += AttributedString(text)
|
|
}
|
|
}
|
|
return Text(out)
|
|
.font(Fonts.font(11))
|
|
.foregroundStyle(Palette.muted)
|
|
.padding(.top, 2)
|
|
}
|
|
}
|