montana/_internal-private/Apps/macOS/Montana/Sources/Montana/ExplorerView.swift
2026-05-26 21:14:51 +03:00

213 lines
7.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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