213 lines
7.4 KiB
Swift
213 lines
7.4 KiB
Swift
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()
|
||
}
|
||
}
|