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

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)) + "" }
}