montana/_internal-private/Apps/macOS/Montana/Sources/Montana/ExplorerView.swift

213 lines
7.4 KiB
Swift
Raw Normal View History

2026-05-26 21:14:51 +03:00
import SwiftUI
// ExplorerView вкладка сети. Тянет https://efir.org/explorer/data.json
// каждые 15 секунд и показывает Genesis-cohort + discovered операторов.
// Только чтение; никаких приватных данных не передаётся.
struct ExplorerView: View {
@StateObject private var loader = ExplorerLoader()
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
header
if let s = loader.doc?.network_summary { summaryCard(s) }
if let nodes = loader.doc?.nodes, !nodes.isEmpty {
Card(titleKey: "net.genesis") {
VStack(spacing: 8) { ForEach(nodes) { NodeRow(node: $0) } }
}
}
if let peers = loader.doc?.discovered_peers, !peers.isEmpty {
Card(titleKey: "net.discovered") {
VStack(spacing: 8) { ForEach(peers) { PeerRow(peer: $0) } }
}
}
if let err = loader.lastError {
Text("\(L("net.error")): \(err)")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.red)
}
}
.padding(20)
}
.task { loader.start() }
.onDisappear { loader.stop() }
}
private var header: some View {
HStack {
TR("net.header").font(.system(size: 16, weight: .semibold))
Spacer()
if let at = loader.lastFetchAt {
Text("\(L("net.updated")) \(timeFmt.string(from: at))")
.font(.caption).foregroundColor(.secondary)
}
Button(action: { loader.refreshNow() }) {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
.help(L("net.refresh"))
}
}
private func summaryCard(_ s: NetworkSummary) -> some View {
Card(titleKey: "net.summary") {
HStack(spacing: 28) {
StatBlock(label: L("net.summary.active"), value: "\(s.active_nodes) / \(s.total_nodes)")
StatBlock(label: L("net.summary.discovered"), value: "\(s.discovered_peer_count)")
StatBlock(label: L("net.summary.window"), value: "\(s.max_window)")
StatBlock(label: L("net.summary.supply"), value: formatSupply(s.total_supply_nj))
}
}
}
private let timeFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "HH:mm:ss"; return f
}()
private func formatSupply(_ nj: UInt64) -> String {
let coin = Double(nj) / 1_000_000_000_000.0
return String(format: "%.2f M", coin / 1_000_000.0)
}
}
private struct StatBlock: View {
let label: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(label).font(.caption).foregroundColor(.secondary)
Text(value).font(.system(size: 16, weight: .semibold, design: .monospaced))
}
}
}
private struct NodeRow: View {
let node: ExplorerNode
var body: some View {
HStack(spacing: 10) {
Circle().fill(node.status == "active" ? Color.green : Color.red)
.frame(width: 8, height: 8)
Text(node.label).font(.system(size: 13, weight: .medium)).frame(width: 110, alignment: .leading)
Text(node.host).font(.system(size: 11, design: .monospaced)).foregroundColor(.secondary)
.frame(width: 90, alignment: .leading)
Spacer()
if let w = node.current_window {
Text("W \(w)").font(.system(size: 12, design: .monospaced))
}
if let p = node.phase {
Text(p).font(.system(size: 11)).foregroundColor(.secondary)
.frame(width: 110, alignment: .trailing)
}
}
}
}
private struct PeerRow: View {
let peer: ExplorerPeer
var body: some View {
HStack(spacing: 10) {
Circle().fill(peer.status == "active" ? Color.green : Color.gray)
.frame(width: 8, height: 8)
Text(peer.label ?? "external").font(.system(size: 13, weight: .medium))
.frame(width: 110, alignment: .leading)
Text(String(peer.peer_id.prefix(14)) + "")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
.frame(width: 130, alignment: .leading)
Spacer()
if let hb = peer.last_heartbeat_seconds_ago {
Text("hb \(hb)s").font(.system(size: 11, design: .monospaced))
}
if let up = peer.uptime_seconds {
Text("up \(formatUptime(up))")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
.frame(width: 60, alignment: .trailing)
}
}
}
private func formatUptime(_ s: Int) -> String {
if s < 60 { return "\(s)s" }
if s < 3600 { return "\(s/60)m" }
if s < 86400 { return "\(s/3600)h" }
return "\(s/86400)d"
}
}
// MARK: Loader + model
struct ExplorerDoc: Decodable {
let updated: String
let nodes: [ExplorerNode]
let discovered_peers: [ExplorerPeer]
let network_summary: NetworkSummary
}
struct ExplorerNode: Decodable, Identifiable {
let label: String
let host: String
let status: String
let current_window: UInt64?
let phase: String?
var id: String { label }
}
struct ExplorerPeer: Decodable, Identifiable {
let peer_id: String
let label: String?
let remote_ip: String
let last_heartbeat_seconds_ago: Int?
let uptime_seconds: Int?
let status: String
var id: String { peer_id }
}
struct NetworkSummary: Decodable {
let active_nodes: Int
let total_nodes: Int
let discovered_peer_count: Int
let max_window: UInt64
let total_supply_nj: UInt64
}
@MainActor final class ExplorerLoader: ObservableObject {
@Published var doc: ExplorerDoc?
@Published var lastError: String?
@Published var lastFetchAt: Date?
private var timer: Timer?
func start() {
fetch()
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { _ in
Task { @MainActor in self.fetch() }
}
}
func stop() { timer?.invalidate(); timer = nil }
func refreshNow() { fetch() }
private func fetch() {
guard let url = URL(string: "https://efir.org/explorer/data.json") else { return }
var req = URLRequest(url: url)
req.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
req.timeoutInterval = 8
URLSession.shared.dataTask(with: req) { [weak self] data, _, error in
DispatchQueue.main.async {
guard let self else { return }
self.lastFetchAt = Date()
if let error = error { self.lastError = error.localizedDescription; return }
guard let data = data else { self.lastError = "пустой ответ"; return }
do {
self.doc = try JSONDecoder().decode(ExplorerDoc.self, from: data)
self.lastError = nil
} catch {
self.lastError = "разбор JSON: \(error)"
}
}
}.resume()
}
}