import SwiftUI private func bytes(_ n: Int64) -> String { n.formatted(.byteCount(style: .file, spellsOutZero: false)) } /// `admin stats`: how big the instance is, and where its disk goes. struct AdminStatsView: View { @State private var model: AdminInstanceViewModel init(client: GitbayClient) { _model = State(initialValue: AdminInstanceViewModel(client: client)) } var body: some View { List { if let s = model.stats.value { Section("Counts") { LabeledContent("Accounts", value: String(s.counts.users)) LabeledContent("Organizations", value: String(s.counts.orgs)) LabeledContent("Repositories", value: String(s.counts.repos)) LabeledContent("Issues", value: "\(s.counts.openIssues) open of \(s.counts.issues)") LabeledContent("Merge requests", value: "\(s.counts.openMRs) open of \(s.counts.mrs)") } Section("Disk") { LabeledContent("Database", value: bytes(s.dbBytes)) LabeledContent("Repositories", value: bytes(s.repoBytes)) LabeledContent("LFS", value: bytes(s.lfsBytes)) } if !s.repos.isEmpty { Section("By repository") { ForEach(s.repos) { repo in LabeledContent(repo.path, value: bytes(repo.bytes)) } } } } } .font(.gbSans(.subheadline)) .overlay { LoadStateOverlay(state: model.stats) } .navigationTitle("Statistics") .navigationBarTitleDisplayMode(.inline) .task { await model.loadStats() } .refreshable { await model.loadStats() } } } /// `audit`: the instance's record of administrative and command events, /// newest first, narrowed by actor, action and age. struct AdminAuditView: View { @State private var model: AdminInstanceViewModel init(client: GitbayClient) { _model = State(initialValue: AdminInstanceViewModel(client: client)) } var body: some View { List { Section { filter("Actor", "username, or - for the system", Bindable(model).auditActor, id: "audit-actor") filter("Action", "starts with, e.g. cmd admin", Bindable(model).auditAction, id: "audit-action") filter("Since", "24h, 7d or 2026-10-01", Bindable(model).auditSince, id: "audit-since") } footer: { Text("The latest 200 that match.") } ForEach(model.audit.value ?? []) { entry in VStack(alignment: .leading, spacing: 2) { Text(entry.action) .font(.gbMono(.caption).weight(.medium)) HStack(spacing: 4) { Text(entry.actor ?? "system") Text("·") Text(entry.createdAt.formatted(date: .abbreviated, time: .shortened)) } .font(.gbSans(.caption2)) .foregroundStyle(.secondary) if let data = entry.data, data != "{}" { Text(data) .font(.gbMono(.caption2)) .foregroundStyle(.secondary) .lineLimit(3) .textSelection(.enabled) } } .accessibilityElement(children: .combine) } } .overlay { if model.audit.value == nil { LoadStateOverlay(state: model.audit) } } .navigationTitle("Audit Log") .navigationBarTitleDisplayMode(.inline) .task { await model.loadAudit() } .refreshable { await model.loadAudit() } } private func filter(_ title: String, _ prompt: String, _ text: Binding, id: String) -> some View { LabeledContent(title) { TextField(prompt, text: text) .multilineTextAlignment(.trailing) .autocorrectionDisabled() .textInputAutocapitalization(.never) .onSubmit { Task { await model.loadAudit() } } .accessibilityIdentifier(id) } .font(.gbSans(.subheadline)) } } /// `admin runners`: the build queue, and each key polling for builds with /// what it may claim and the build it holds. struct AdminRunnersView: View { @State private var model: AdminInstanceViewModel @State private var removing: AdminRunners.Runner? init(client: GitbayClient) { _model = State(initialValue: AdminInstanceViewModel(client: client)) } var body: some View { List { if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } if let r = model.runners.value { Section("Queue") { LabeledContent("Waiting", value: String(r.queue.pending)) LabeledContent("Claimed in 24h", value: String(r.queue.claimed24h)) LabeledContent("Wait", value: "\(r.queue.claimWaitAvgS)s average, \(r.queue.claimWaitMaxS)s longest") LabeledContent("Reaped in 24h", value: String(r.queue.reaped24h)) } .font(.gbSans(.subheadline)) Section { if r.runners.isEmpty { Text("No key has polled for builds.") .font(.gbSans(.subheadline)) .foregroundStyle(.secondary) } ForEach(r.runners) { runner in VStack(alignment: .leading, spacing: 2) { Text(runner.username).font(.gbSans(.subheadline).weight(.medium)) Text(runner.fingerprint) .font(.gbMono(.caption)) .lineLimit(1) .truncationMode(.middle) Text("polled \(runner.lastSeen.formatted(.relative(presentation: .named))) · claims \(runner.scope ?? "any repository")") .font(.gbSans(.caption2)) .foregroundStyle(.secondary) if let repo = runner.buildRepo, let n = runner.buildNumber { Text("holds \(repo) #\(n)\(runner.buildJob.map { " \($0)" } ?? "")") .font(.gbSans(.caption2)) .foregroundStyle(Color.gbWarn) } } .accessibilityElement(children: .combine) .swipeActions { Button("Remove", role: .destructive) { removing = runner } } } } header: { Text("Runners") } footer: { Text("Removing drops a key's heartbeat row, say one that polled once by mistake. The key stays.") } } } .overlay { LoadStateOverlay(state: model.runners) } .navigationTitle("Runners") .navigationBarTitleDisplayMode(.inline) .confirmationDialog( removing.map { "Remove \($0.username)'s runner row?" } ?? "", isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), titleVisibility: .visible, presenting: removing ) { runner in Button("Remove", role: .destructive) { Task { await model.removeRunner(runner) } } Button("Cancel", role: .cancel) {} } .task { await model.loadRunners() } .refreshable { await model.loadRunners() } } } /// `admin mail inbound check`: whether the reply mailbox answers, and /// what waits in it. struct AdminInboundView: View { @State private var model: AdminInstanceViewModel init(client: GitbayClient) { _model = State(initialValue: AdminInstanceViewModel(client: client)) } var body: some View { List { if let check = model.inbound.value { Section { LabeledContent("Reading replies", value: check.enabled ? "on" : "off") .accessibilityIdentifier("inbound-enabled") if check.enabled { if let server = check.server { LabeledContent("Server", value: server) } if let mailbox = check.mailbox { LabeledContent("Mailbox", value: mailbox) } LabeledContent("Messages", value: "\(check.messages), \(check.unseen) unseen") LabeledContent("DKIM required", value: check.requireDKIM ? "yes" : "no") } } footer: { if let warning = check.warning { Text(warning).foregroundStyle(Color.gbWarn) } else if !check.enabled { Text("[mail.inbound] is off, so replies to notification mail are not read.") } } .font(.gbSans(.subheadline)) } } .overlay { if case .loading = model.inbound { // A live connection to the mailbox; one that does not answer // takes its timeout, twice with the retry. ProgressView("Connecting to the mailbox. This can take a minute when it does not answer.") .font(.gbSans(.subheadline)) .multilineTextAlignment(.center) .padding() } else { LoadStateOverlay(state: model.inbound) } } .navigationTitle("Inbound Mail") .navigationBarTitleDisplayMode(.inline) .task { await model.checkInbound() } .refreshable { await model.checkInbound() } } }