Merge nucleic/gentle-slate-seal into dev
This commit is contained in:
@@ -90,6 +90,27 @@ final class RemoteStore: ObservableObject {
|
||||
.compactMapValues { RunnerPresence.decode($0) }
|
||||
}
|
||||
|
||||
// MARK: Mesh info (read-only projections for the Mesh Info screen)
|
||||
|
||||
/// Live connectivity for a directly-paired Mac, keyed by its `PairedHost.fingerprint` — the
|
||||
/// same id `connections` is keyed by. `nil` when no connection object exists for it yet.
|
||||
func connectivity(forPairedHost fingerprint: String) -> Connectivity? {
|
||||
connections[fingerprint]?.connectivity
|
||||
}
|
||||
|
||||
/// The mesh's runner-presence cards (Macs / cloud runners advertising capacity), sorted by
|
||||
/// host name — the phone's window into the Carbon dispatch candidate pool. Keyed by the full
|
||||
/// `deviceID` (distinct from `PairedHost.fingerprint`), so it's presented as its own section
|
||||
/// rather than cross-matched against the directly-paired Macs.
|
||||
var meshRunnerCards: [(hostID: String, presence: RunnerPresence)] {
|
||||
runnerPresenceByHost
|
||||
.map { (hostID: $0.key, presence: $0.value) }
|
||||
.sorted {
|
||||
$0.presence.hostName.localizedCaseInsensitiveCompare($1.presence.hostName)
|
||||
== .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the composer should offer "Auto (Mesh)" for the project the phone has selected:
|
||||
/// some connected host advertising `canAcknowledgeDispatch` (other than the project's own
|
||||
/// owner) holds a matching repo. The descriptor comes from the owning host's presence card.
|
||||
|
||||
@@ -76,8 +76,8 @@ private struct ActivityFeedRow: View {
|
||||
store.route(to: event.sessionID)
|
||||
} label: {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: Self.icon(for: event.kind))
|
||||
.foregroundStyle(Self.tint(for: event.kind))
|
||||
Image(systemName: ActivityFeedIcon.icon(for: event.kind))
|
||||
.foregroundStyle(ActivityFeedIcon.tint(for: event.kind))
|
||||
.font(.subheadline)
|
||||
.frame(width: 20)
|
||||
.padding(.top, 2)
|
||||
@@ -105,12 +105,16 @@ private struct ActivityFeedRow: View {
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
var parts: [String] = [Self.headline(for: event)]
|
||||
var parts: [String] = [ActivityFeedIcon.headline(for: event)]
|
||||
if !event.projectName.isEmpty { parts.append(event.projectName) }
|
||||
if !event.hostName.isEmpty { parts.append(event.hostName) }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared icon / tint / headline vocabulary for a fleet-activity `FeedEvent`, used by both the
|
||||
/// activity feed rows and the Mesh Info screen's event log so they read identically.
|
||||
enum ActivityFeedIcon {
|
||||
static func headline(for event: FeedEvent) -> String {
|
||||
switch event.kind {
|
||||
case .finished: "Finished"
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// The phone's **Mesh Info** screen — the iOS analogue of the Mac's Carbon Mesh Viewer, reached
|
||||
/// from Settings ▸ Mesh ▸ "Mesh info". A read-only diagnostics surface over the mesh as this phone
|
||||
/// sees it: overall connection health, the Macs this phone is directly paired with (live status +
|
||||
/// transport), the mesh's runner-presence cards (the Carbon dispatch candidate pool with capacity /
|
||||
/// projects), and a live tail of fleet activity. Structural metadata only — never prompt/draft
|
||||
/// content, matching the Mac viewer's redaction invariant.
|
||||
///
|
||||
/// The phone links only `NucleicProtocol`, not the Mac's mesh engine, so this renders from
|
||||
/// `RemoteStore` projections (`connectivity`, `meshRunnerCards`, `activityFeed`) rather than the
|
||||
/// Mac's `MeshDiagnosticsLog`. Two id namespaces are in play — directly-paired Macs are keyed by
|
||||
/// `PairedHost.fingerprint`, presence cards by the full `deviceID` — so they're shown as separate
|
||||
/// sections instead of being cross-matched.
|
||||
struct MeshInfoView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
|
||||
private var pairedHosts: [PairedHost] { IdentityStore.pairedHosts() }
|
||||
private var runnerCards: [(hostID: String, presence: RunnerPresence)] { store.meshRunnerCards }
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
statusSection
|
||||
yourMacsSection
|
||||
runnerCardsSection
|
||||
activitySection
|
||||
}
|
||||
.navigationTitle("Mesh info")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
// MARK: Status
|
||||
|
||||
@ViewBuilder
|
||||
private var statusSection: some View {
|
||||
Section {
|
||||
LabeledContent("Connection", value: store.connectivity.label)
|
||||
LabeledContent("Paired Macs", value: "\(pairedHosts.count)")
|
||||
if !runnerCards.isEmpty {
|
||||
LabeledContent("Runner cards", value: "\(runnerCards.count)")
|
||||
}
|
||||
} header: {
|
||||
Text("Status")
|
||||
} footer: {
|
||||
Text("The mesh as this device sees it. Read-only — structural metadata only.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Your Macs
|
||||
|
||||
@ViewBuilder
|
||||
private var yourMacsSection: some View {
|
||||
if !pairedHosts.isEmpty {
|
||||
Section("Your Macs") {
|
||||
ForEach(pairedHosts, id: \.fingerprint) { host in
|
||||
let connectivity = store.connectivity(forPairedHost: host.fingerprint)
|
||||
HStack(spacing: 10) {
|
||||
statusDot(online: connectivity?.isLive == true)
|
||||
Image(systemName: "desktopcomputer").foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(host.hostName.isEmpty ? "Mac" : host.hostName)
|
||||
Text(connectivity?.label ?? "Not connected")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Runner presence cards
|
||||
|
||||
@ViewBuilder
|
||||
private var runnerCardsSection: some View {
|
||||
if !runnerCards.isEmpty {
|
||||
Section {
|
||||
ForEach(runnerCards, id: \.hostID) { card in
|
||||
RunnerCardRow(presence: card.presence)
|
||||
}
|
||||
} header: {
|
||||
Text("Runner capacity")
|
||||
} footer: {
|
||||
Text("Hosts advertising capacity for Carbon-dispatched chats, and the projects "
|
||||
+ "they hold.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Recent activity
|
||||
|
||||
@ViewBuilder
|
||||
private var activitySection: some View {
|
||||
Section("Recent mesh activity") {
|
||||
if store.activityFeed.isEmpty {
|
||||
Text("No mesh events yet.")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
} else {
|
||||
// Newest first, capped — this is a diagnostics tail, not the full feed.
|
||||
ForEach(Array(store.activityFeed.reversed().prefix(50))) { item in
|
||||
MeshEventRow(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Bits
|
||||
|
||||
private func statusDot(online: Bool) -> some View {
|
||||
Circle()
|
||||
.fill(online ? Color.green : Color.secondary.opacity(0.4))
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
|
||||
/// One runner-presence card: host name + kind, load (active / capacity), and its held projects.
|
||||
private struct RunnerCardRow: View {
|
||||
let presence: RunnerPresence
|
||||
|
||||
private var capacityLabel: String {
|
||||
let cap = presence.maxConcurrent.map(String.init) ?? "∞"
|
||||
return "\(presence.activeSessionCount)/\(cap)"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: presence.kind == .cloud ? "cloud" : "desktopcomputer")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(presence.hostName.isEmpty ? "Host" : presence.hostName)
|
||||
Text("\(presence.projects.count) project(s)"
|
||||
+ (presence.acceptsMeshDispatch ? " · accepts dispatch" : " · dispatch off"))
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(capacityLabel)
|
||||
.font(.footnote.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One mesh event row (a fleet-activity `FeedEvent`) — the phone's equivalent of the Mac viewer's
|
||||
/// event-log rows. Reuses the activity feed's icon/tint/headline vocabulary.
|
||||
private struct MeshEventRow: View {
|
||||
let item: ActivityFeedItem
|
||||
private var event: FeedEvent { item.event }
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: ActivityFeedIcon.icon(for: event.kind))
|
||||
.foregroundStyle(ActivityFeedIcon.tint(for: event.kind))
|
||||
.font(.subheadline)
|
||||
.frame(width: 20)
|
||||
.padding(.top, 2)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(event.sessionTitle.isEmpty ? "Untitled chat" : event.sessionTitle)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
Text(event.at, format: .relative(presentation: .named))
|
||||
.font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
Text(subtitle)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
var parts: [String] = [ActivityFeedIcon.headline(for: event)]
|
||||
if !event.projectName.isEmpty { parts.append(event.projectName) }
|
||||
if !event.hostName.isEmpty { parts.append(event.hostName) }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,13 @@ struct SettingsView: View {
|
||||
let pairedHosts = IdentityStore.pairedHosts()
|
||||
if !pairedHosts.isEmpty {
|
||||
Section("Mesh") {
|
||||
// A read-only diagnostics view of the mesh as this phone sees it — the
|
||||
// iOS analogue of the Mac's Carbon Mesh Viewer.
|
||||
NavigationLink {
|
||||
MeshInfoView()
|
||||
} label: {
|
||||
Label("Mesh info", systemImage: "point.3.connected.trianglepath.dotted")
|
||||
}
|
||||
ForEach(pairedHosts, id: \.fingerprint) { host in
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "desktopcomputer").foregroundStyle(.secondary)
|
||||
|
||||
Reference in New Issue
Block a user