Merge nucleic/gentle-slate-seal into dev
This commit is contained in:
@@ -90,6 +90,27 @@ final class RemoteStore: ObservableObject {
|
|||||||
.compactMapValues { RunnerPresence.decode($0) }
|
.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:
|
/// 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
|
/// 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.
|
/// 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)
|
store.route(to: event.sessionID)
|
||||||
} label: {
|
} label: {
|
||||||
HStack(alignment: .top, spacing: 10) {
|
HStack(alignment: .top, spacing: 10) {
|
||||||
Image(systemName: Self.icon(for: event.kind))
|
Image(systemName: ActivityFeedIcon.icon(for: event.kind))
|
||||||
.foregroundStyle(Self.tint(for: event.kind))
|
.foregroundStyle(ActivityFeedIcon.tint(for: event.kind))
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.frame(width: 20)
|
.frame(width: 20)
|
||||||
.padding(.top, 2)
|
.padding(.top, 2)
|
||||||
@@ -105,12 +105,16 @@ private struct ActivityFeedRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var subtitle: String {
|
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.projectName.isEmpty { parts.append(event.projectName) }
|
||||||
if !event.hostName.isEmpty { parts.append(event.hostName) }
|
if !event.hostName.isEmpty { parts.append(event.hostName) }
|
||||||
return parts.joined(separator: " · ")
|
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 {
|
static func headline(for event: FeedEvent) -> String {
|
||||||
switch event.kind {
|
switch event.kind {
|
||||||
case .finished: "Finished"
|
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()
|
let pairedHosts = IdentityStore.pairedHosts()
|
||||||
if !pairedHosts.isEmpty {
|
if !pairedHosts.isEmpty {
|
||||||
Section("Mesh") {
|
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
|
ForEach(pairedHosts, id: \.fingerprint) { host in
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: "desktopcomputer").foregroundStyle(.secondary)
|
Image(systemName: "desktopcomputer").foregroundStyle(.secondary)
|
||||||
|
|||||||
Reference in New Issue
Block a user