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()
|
|||
|
|
}
|
|||
|
|
}
|