218 lines
7.9 KiB
Swift
218 lines
7.9 KiB
Swift
import SwiftUI
|
|
|
|
struct ContentView: View {
|
|
@StateObject private var service = NodeService()
|
|
@State private var refreshTimer: Timer?
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
Divider()
|
|
TabView {
|
|
NodeStatusView(service: service)
|
|
.tabItem { Label(L("tab.node"), systemImage: "server.rack") }
|
|
WalletView(service: service)
|
|
.tabItem { Label(L("tab.wallet"), systemImage: "wallet.pass") }
|
|
ExplorerView()
|
|
.tabItem { Label(L("tab.network"), systemImage: "globe") }
|
|
}
|
|
Divider()
|
|
actionBar
|
|
}
|
|
.frame(minWidth: 760, minHeight: 660)
|
|
.task {
|
|
await service.refresh()
|
|
startTimer()
|
|
}
|
|
.onDisappear { refreshTimer?.invalidate() }
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 14) {
|
|
if let url = Bundle.module.url(forResource: "Montana_icon_1024", withExtension: "png"), let icon = NSImage(contentsOf: url) {
|
|
Image(nsImage: icon)
|
|
.resizable()
|
|
.interpolation(.high)
|
|
.frame(width: 56, height: 56)
|
|
.cornerRadius(12)
|
|
}
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Montana").font(.system(size: 22, weight: .semibold))
|
|
TR("app.subtitle")
|
|
.font(.system(size: 12, design: .monospaced))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
Spacer()
|
|
LanguagePicker()
|
|
statusPill
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 14)
|
|
}
|
|
|
|
private var statusPill: some View {
|
|
HStack(spacing: 6) {
|
|
Circle().fill(service.isRunning ? Color.green : Color.gray).frame(width: 10, height: 10)
|
|
TR(service.isRunning ? "status.running" : "status.stopped")
|
|
.font(.system(size: 12, weight: .medium))
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Color.secondary.opacity(0.1))
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
private var actionBar: some View {
|
|
HStack(spacing: 10) {
|
|
Button(L("action.restart")) { Task { await service.restart() } }
|
|
Button(L("action.stop")) { Task { await service.stop() } }
|
|
.disabled(!service.isRunning)
|
|
Button(L("action.start")) { Task { await service.start() } }
|
|
.disabled(service.isRunning)
|
|
Spacer()
|
|
Button(L("action.logs")) { service.openLogs() }
|
|
Button(L("action.data_folder")) { service.revealDataFolder() }
|
|
Button(action: { Task { await service.refresh() } }) {
|
|
Image(systemName: "arrow.clockwise")
|
|
}
|
|
.help(L("action.refresh"))
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 12)
|
|
}
|
|
|
|
private func startTimer() {
|
|
refreshTimer?.invalidate()
|
|
refreshTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in
|
|
Task { @MainActor in await service.refresh() }
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct NodeStatusView: View {
|
|
@ObservedObject var service: NodeService
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
statusCard
|
|
operatorCard
|
|
networkCard
|
|
logCard
|
|
}
|
|
.padding(20)
|
|
}
|
|
}
|
|
|
|
private var statusCard: some View {
|
|
Card(titleKey: "card.status") {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
row(L("row.phase"), service.status.phase)
|
|
row(L("row.window"), "\(service.status.currentWindow)")
|
|
row(L("row.d"), formatNumber(service.status.d))
|
|
if service.status.candidateTotal > 0 {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
TR("row.candidate_vdf").font(.system(size: 12)).foregroundColor(.secondary)
|
|
Spacer()
|
|
Text("\(service.status.candidateDone) / \(service.status.candidateTotal)")
|
|
.font(.system(size: 12, design: .monospaced))
|
|
}
|
|
ProgressView(value: service.status.candidateProgress)
|
|
.progressViewStyle(.linear)
|
|
}
|
|
}
|
|
if let err = service.status.error {
|
|
Text(err).font(.system(size: 11, design: .monospaced)).foregroundColor(.red)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var operatorCard: some View {
|
|
Card(titleKey: "card.operator") {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
row(L("row.account_id"), service.status.accountId.prefix16)
|
|
row(L("row.node_id"), service.status.nodeId.prefix16)
|
|
row(L("row.balance"), service.status.balance)
|
|
row(L("row.account_chain_length"), "\(service.status.accountChainLength)")
|
|
row(L("row.is_node_operator"), L(service.status.isNodeOperator ? "row.true" : "row.false"))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var networkCard: some View {
|
|
Card(titleKey: "card.network") {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
row(L("row.account_table"), "\(service.status.accountTable) " + L("row.account_table.unit"))
|
|
row(L("row.node_table"), "\(service.status.nodeTable) " + L("row.node_table.unit"))
|
|
row(L("row.candidate_pool"), "\(service.status.candidatePool) " + L("row.candidate_pool.unit"))
|
|
row(L("row.supply"), service.status.supply)
|
|
row(L("row.sum_balances"), service.status.sumBalances)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var logCard: some View {
|
|
Card(titleKey: "card.log") {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
if service.lastLogLines.isEmpty {
|
|
TR("log.empty").foregroundColor(.secondary).font(.system(size: 12))
|
|
} else {
|
|
ForEach(Array(service.lastLogLines.enumerated()), id: \.offset) { _, line in
|
|
Text(line)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func row(_ label: String, _ value: String) -> some View {
|
|
HStack {
|
|
Text(label).font(.system(size: 12)).foregroundColor(.secondary)
|
|
Spacer()
|
|
Text(value).font(.system(size: 12, design: .monospaced)).textSelection(.enabled)
|
|
}
|
|
}
|
|
|
|
private func formatNumber(_ n: UInt64) -> String {
|
|
let f = NumberFormatter()
|
|
f.numberStyle = .decimal
|
|
f.groupingSeparator = " "
|
|
return f.string(from: NSNumber(value: n)) ?? "\(n)"
|
|
}
|
|
}
|
|
|
|
struct Card<Content: View>: View {
|
|
let title: String
|
|
@ViewBuilder let content: () -> Content
|
|
|
|
init(title: String, @ViewBuilder content: @escaping () -> Content) {
|
|
self.title = title
|
|
self.content = content
|
|
}
|
|
|
|
init(titleKey: String, @ViewBuilder content: @escaping () -> Content) {
|
|
self.title = L(titleKey)
|
|
self.content = content
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(title).font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(.secondary).textCase(.uppercase).tracking(1)
|
|
content()
|
|
}
|
|
.padding(14)
|
|
.background(RoundedRectangle(cornerRadius: 10).fill(Color.secondary.opacity(0.06)))
|
|
}
|
|
}
|
|
|
|
extension String {
|
|
var prefix16: String { count <= 16 ? self : String(prefix(16)) + "…" }
|
|
}
|