438 lines
18 KiB
Swift
438 lines
18 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
|
|
struct ServerMetricsView: View {
|
|
@Environment(AppModel.self) private var app
|
|
@Environment(WindowModel.self) private var window
|
|
@State private var detailObserver = UUID()
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
private var selectedServer: ServerMetric? {
|
|
app.serverMetrics.servers.first(where: { $0.id == window.metricsHostID })
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
if let server = selectedServer {
|
|
Group {
|
|
if server.showsConnectionJourney && !server.isHostLocked {
|
|
MetricsJourneyView(server: server, model: app.serverMetrics)
|
|
} else {
|
|
detail(server)
|
|
}
|
|
}
|
|
.transition(
|
|
.asymmetric(
|
|
insertion: .move(edge: .trailing).combined(with: .opacity),
|
|
removal: .move(edge: .trailing).combined(with: .opacity)))
|
|
} else {
|
|
MetricsManagedCardPage()
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.clipped()
|
|
.animation(Motion.panel(reduceMotion: reduceMotion), value: window.metricsHostID)
|
|
.animation(Motion.panel(reduceMotion: reduceMotion), value: selectedServer?.showsConnectionJourney)
|
|
.background(TermindColor.windowSurface)
|
|
.onAppear { observeDetails() }
|
|
.onChange(of: window.metricsHostID) { observeDetails() }
|
|
.onChange(of: selectedServer?.showsConnectionJourney) { observeDetails() }
|
|
.onChange(of: app.serverMetrics.servers.map(\.id)) { _, ids in
|
|
if let id = window.metricsHostID, !ids.contains(id) { window.metricsHostID = nil }
|
|
}
|
|
.onDisappear { app.serverMetrics.stopObservingDetails(detailObserver) }
|
|
}
|
|
|
|
private func observeDetails() {
|
|
if let server = selectedServer, !server.showsConnectionJourney {
|
|
app.serverMetrics.observeDetails(hostID: server.id, observer: detailObserver)
|
|
} else {
|
|
app.serverMetrics.stopObservingDetails(detailObserver)
|
|
}
|
|
}
|
|
|
|
private func detail(_ server: ServerMetric) -> some View {
|
|
MetricsDetailView(server: server)
|
|
}
|
|
|
|
}
|
|
|
|
struct MetricsDetailView: View {
|
|
let server: ServerMetric
|
|
@Environment(AppModel.self) private var app
|
|
@Environment(WindowModel.self) private var window
|
|
|
|
var body: some View {
|
|
if server.isHostLocked {
|
|
Button {
|
|
app.proAccess.presentPaywall()
|
|
} label: {
|
|
MetricsHostLockNotice()
|
|
.padding(24)
|
|
.frame(maxHeight: .infinity)
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
detail
|
|
}
|
|
}
|
|
|
|
private var detail: some View {
|
|
GeometryReader { geometry in
|
|
let split = MetricsDetailPage.showsBothColumns(width: geometry.size.width)
|
|
let showsStatus = split || window.metricsDetailPage == .status
|
|
let showsProcesses = split || window.metricsDetailPage == .processes
|
|
|
|
MetricsDetailLayout(split: split, page: window.metricsDetailPage) {
|
|
ScrollView {
|
|
MetricsServerDetails(server: server)
|
|
.padding(1)
|
|
}
|
|
.metricsPlainScrollEdges()
|
|
.opacity(showsStatus ? 1 : 0)
|
|
.allowsHitTesting(showsStatus)
|
|
.disabled(!showsStatus)
|
|
.accessibilityHidden(!showsStatus)
|
|
|
|
MetricsProcessList(server: server)
|
|
.opacity(showsProcesses ? 1 : 0)
|
|
.allowsHitTesting(showsProcesses)
|
|
.disabled(!showsProcesses)
|
|
.accessibilityHidden(!showsProcesses)
|
|
}
|
|
.padding(16)
|
|
.clipped()
|
|
}
|
|
.onGeometryChange(for: CGFloat.self) {
|
|
$0.size.width
|
|
} action: {
|
|
window.metricsDetailWidth = $0
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ServerMetricsCard: View {
|
|
let server: ServerMetric
|
|
let model: ServerMetricsModel
|
|
var compact = false
|
|
var inspect: (() -> Void)?
|
|
var upgrade: (() -> Void)?
|
|
var openTerminal: (() -> Void)?
|
|
var isSelected = false
|
|
var select: (() -> Void)?
|
|
var extendSelection: (() -> Void)?
|
|
var contextActionItems: [ServerMetric] = []
|
|
@State private var configuring = false
|
|
|
|
var body: some View {
|
|
CardTile(
|
|
selectionID: server.id, isSelected: isSelected,
|
|
select: select ?? {}, extendSelection: extendSelection,
|
|
activate: activate, activatesOnReturn: !compact, activatesOnSingleClick: true
|
|
) {
|
|
cardContent
|
|
}
|
|
.contextMenu {
|
|
if contextActionItems.count > 1 {
|
|
if contextActionItems.contains(where: { $0.selection.enabled }) {
|
|
Button(AppStrings.Metrics.pause.swiftUIKey, systemImage: MenuSymbol.pause) {
|
|
for item in contextActionItems where item.selection.enabled {
|
|
model.setEnabled(false, for: item)
|
|
}
|
|
}
|
|
}
|
|
if contextActionItems.contains(where: { !$0.selection.enabled }) {
|
|
Button(AppStrings.Metrics.resume.swiftUIKey, systemImage: MenuSymbol.start) {
|
|
for item in contextActionItems where !item.selection.enabled {
|
|
model.setEnabled(true, for: item)
|
|
}
|
|
}
|
|
}
|
|
Button(AppStrings.Common.retry.swiftUIKey, systemImage: MenuSymbol.refresh) {
|
|
for item in contextActionItems { model.retry(item) }
|
|
}
|
|
Divider()
|
|
DestructiveMenuButton(AppStrings.Common.delete) {
|
|
for item in contextActionItems { model.remove(item.id) }
|
|
}
|
|
} else {
|
|
singleServerMenu
|
|
}
|
|
}
|
|
.sheet(isPresented: $configuring) {
|
|
MetricsDevicePicker(server: server, model: model)
|
|
.allowsApplicationTerminationWhenModal()
|
|
}
|
|
}
|
|
|
|
private var cardContent: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(spacing: 10) {
|
|
if select == nil, inspect != nil {
|
|
Button(action: activate) { Text(server.name).font(.subheadline.bold()).lineLimit(1) }.buttonStyle(
|
|
.plain)
|
|
} else {
|
|
Text(server.name).font(.subheadline.bold()).lineLimit(1)
|
|
}
|
|
Spacer(minLength: 0)
|
|
if let openTerminal {
|
|
Button(action: openTerminal) { Image(systemName: "terminal") }
|
|
.buttonStyle(.plain).foregroundStyle(TermindColor.secondaryForeground).help(
|
|
AppStrings.Metrics.openTerminal.swiftUIKey)
|
|
}
|
|
if !compact {
|
|
Menu {
|
|
singleServerMenu
|
|
} label: {
|
|
Image(systemName: "ellipsis")
|
|
}
|
|
.menuStyle(.borderlessButton).fixedSize()
|
|
}
|
|
}
|
|
if server.isHostLocked {
|
|
MetricsHostLockNotice()
|
|
} else if select == nil, let inspect {
|
|
Button(action: inspect) { cardBody }.buttonStyle(.plain)
|
|
} else {
|
|
cardBody
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
func activate() {
|
|
if server.isHostLocked { upgrade?() } else { inspect?() }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var singleServerMenu: some View {
|
|
if inspect != nil {
|
|
Button(AppStrings.Metrics.details.swiftUIKey, systemImage: MenuSymbol.details, action: activate)
|
|
}
|
|
Button(
|
|
server.selection.enabled ? AppStrings.Metrics.pause.swiftUIKey : AppStrings.Metrics.resume.swiftUIKey,
|
|
systemImage: server.selection.enabled ? MenuSymbol.pause : MenuSymbol.start
|
|
) {
|
|
model.setEnabled(!server.selection.enabled, for: server)
|
|
}
|
|
Button(AppStrings.Common.retry.swiftUIKey, systemImage: MenuSymbol.refresh) { model.retry(server) }
|
|
Button(AppStrings.Metrics.selectDevices.swiftUIKey, systemImage: MenuSymbol.selectDevices) {
|
|
configuring = true
|
|
}
|
|
Divider()
|
|
DestructiveMenuButton(AppStrings.Common.delete) { model.remove(server.id) }
|
|
}
|
|
|
|
private var cardBody: some View {
|
|
MetricsInstrumentBody(server: server, sleeping: model.sleeping)
|
|
}
|
|
|
|
}
|
|
|
|
struct MetricsProcessList: View {
|
|
let server: ServerMetric
|
|
@Environment(WindowModel.self) private var window
|
|
@State private var sortOrder = [KeyPathComparator(\MetricsProcess.cpuSortValue, order: .reverse)]
|
|
private var rows: [MetricsProcess] {
|
|
let query = window.metricsProcessQuery
|
|
return server.processes.filter {
|
|
query.isEmpty || $0.name.localizedCaseInsensitiveContains(query) || String($0.pid).contains(query)
|
|
}.sorted(using: sortOrder)
|
|
}
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(AppStrings.Metrics.processes).font(.headline).frame(maxWidth: .infinity, alignment: .leading)
|
|
if server.processesDate == nil {
|
|
VStack(spacing: 12) {
|
|
if server.processesFailed {
|
|
Image(systemName: "exclamationmark.triangle").font(.title2).foregroundStyle(
|
|
TermindColor.warning)
|
|
Text(AppStrings.Metrics.processMetricsUnavailable).foregroundStyle(.secondary)
|
|
} else {
|
|
ProgressView().controlSize(.regular)
|
|
Text(AppStrings.Metrics.collectingProcessesProgress).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
if server.processesFailed {
|
|
Text(AppStrings.Metrics.processMetricsUnavailableShowingTheLastSample).font(.caption)
|
|
.foregroundStyle(
|
|
TermindColor.warning)
|
|
}
|
|
processTable
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(TermindColor.windowSurface, in: RoundedRectangle(cornerRadius: Metrics.Radius.large))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: Metrics.Radius.large).strokeBorder(.quaternary, lineWidth: 1)
|
|
}
|
|
}
|
|
|
|
private var processTable: some View {
|
|
Table(rows, sortOrder: $sortOrder) {
|
|
TableColumn(AppStrings.Metrics.pid.swiftUIKey, value: \.pid) { process in
|
|
Text(String(process.pid)).foregroundStyle(.secondary).frame(height: 28)
|
|
}
|
|
.width(48)
|
|
TableColumn(AppStrings.Metrics.process.swiftUIKey, value: \.name) { process in
|
|
Text(process.name).lineLimit(1).truncationMode(.tail).help(process.name).frame(height: 28)
|
|
}.width(min: 0, ideal: 180)
|
|
TableColumn(AppStrings.Metrics.cpu.swiftUIKey, value: \.cpuSortValue) { process in
|
|
Text(MetricsFormat.percent(process.cpu)).frame(height: 28)
|
|
}.width(
|
|
58)
|
|
TableColumn(AppStrings.Metrics.memory.swiftUIKey, value: \.memorySortValue) { process in
|
|
Text(MetricsFormat.bytes(process.memory, style: .memory)).frame(height: 28)
|
|
}
|
|
.width(72)
|
|
}
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.tableStyle(.inset(alternatesRowBackgrounds: false))
|
|
.scrollContentBackground(.hidden)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
struct MetricsDevicePicker: View {
|
|
let server: ServerMetric
|
|
let model: ServerMetricsModel
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var disks: Set<String> = []
|
|
@State private var interfaces: Set<String> = []
|
|
@State private var allDisks = true
|
|
@State private var allInterfaces = true
|
|
var body: some View {
|
|
Form {
|
|
Section(AppStrings.Metrics.disks.swiftUIKey) {
|
|
Toggle(AppStrings.Metrics.allDefaultDisks.swiftUIKey, isOn: $allDisks)
|
|
ForEach(server.disks, id: \.self) { name in
|
|
Toggle(name, isOn: selected(name, in: $disks)).disabled(allDisks)
|
|
}
|
|
}
|
|
Section(AppStrings.Metrics.networkInterfaces.swiftUIKey) {
|
|
Toggle(AppStrings.Metrics.defaultNetworkInterfaces.swiftUIKey, isOn: $allInterfaces)
|
|
ForEach(server.interfaces, id: \.self) { name in
|
|
Toggle(name, isOn: selected(name, in: $interfaces)).disabled(allInterfaces)
|
|
}
|
|
}
|
|
HStack {
|
|
Button(AppStrings.Common.cancel.swiftUIKey) { dismiss() }
|
|
Spacer()
|
|
Button(AppStrings.Common.save.swiftUIKey) {
|
|
model.selectDevices(
|
|
server, disks: allDisks ? nil : disks, interfaces: allInterfaces ? nil : interfaces)
|
|
dismiss()
|
|
}.keyboardShortcut(.defaultAction)
|
|
}
|
|
}
|
|
.formStyle(.grouped).frame(width: 440, height: 440)
|
|
.onAppear {
|
|
allDisks = server.selection.disks == nil
|
|
allInterfaces = server.selection.interfaces == nil
|
|
disks = server.selection.disks ?? Set(server.disks)
|
|
interfaces = server.selection.interfaces ?? Set(server.interfaces)
|
|
}
|
|
}
|
|
private func selected(_ name: String, in set: Binding<Set<String>>) -> Binding<Bool> {
|
|
Binding(
|
|
get: { set.wrappedValue.contains(name) },
|
|
set: {
|
|
if $0 { set.wrappedValue.insert(name) } else { set.wrappedValue.remove(name) }
|
|
})
|
|
}
|
|
}
|
|
|
|
struct MetricsMenuView: View {
|
|
let model: ServerMetricsModel
|
|
let openMetrics: () -> Void
|
|
let openDetails: (UUID) -> Void
|
|
|
|
static func preferredSize(serverCount: Int) -> CGSize {
|
|
CGSize(width: 380, height: serverCount == 0 ? 140 : min(520, serverCount * 118 + max(0, serverCount - 1)) + 49)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 8) {
|
|
Text(ApplicationIdentity.displayName).font(.headline)
|
|
Spacer()
|
|
Button(action: openMetrics) {
|
|
Image(systemName: "macwindow").frame(width: 24, height: 24)
|
|
}
|
|
.buttonStyle(.plain).termindInputAwareFocusEffect()
|
|
.help(Text(AppStrings.Metrics.openTermind)).accessibilityLabel(Text(AppStrings.Metrics.openTermind))
|
|
Button(action: { NSApp.terminate(nil) }) {
|
|
Image(systemName: "power").frame(width: 24, height: 24)
|
|
}
|
|
.buttonStyle(.plain).termindInputAwareFocusEffect()
|
|
.help(Text(AppStrings.Metrics.quitTermind)).accessibilityLabel(Text(AppStrings.Metrics.quitTermind))
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
Divider()
|
|
if model.servers.isEmpty {
|
|
Text(AppStrings.Metrics.noMonitoredServers).foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(model.servers) { server in
|
|
Button {
|
|
openDetails(server.id)
|
|
} label: {
|
|
MetricsMenuRow(server: server, sleeping: model.sleeping)
|
|
.contentShape(.rect)
|
|
}
|
|
.buttonStyle(.plain).termindInputAwareFocusEffect()
|
|
.help(Text(AppStrings.Metrics.details))
|
|
.accessibilityLabel(Text(server.name))
|
|
.accessibilityIdentifier("metrics.menu.\(server.id)")
|
|
if server.id != model.servers.last?.id { Divider() }
|
|
}
|
|
}
|
|
}
|
|
.contentMargins(0, for: .scrollContent)
|
|
.scrollContentBackground(.hidden)
|
|
.metricsPlainScrollEdges(.all)
|
|
.frame(height: Self.preferredSize(serverCount: model.servers.count).height - 49)
|
|
}
|
|
}
|
|
.frame(
|
|
width: Self.preferredSize(serverCount: model.servers.count).width,
|
|
height: Self.preferredSize(serverCount: model.servers.count).height)
|
|
}
|
|
}
|
|
|
|
private struct MetricsMenuRow: View {
|
|
let server: ServerMetric
|
|
let sleeping: Bool
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(server.name).font(.subheadline.bold()).lineLimit(1).help(server.name)
|
|
.frame(height: 18, alignment: .leading)
|
|
if server.isHostLocked {
|
|
MetricsHostLockNotice()
|
|
} else {
|
|
MetricsInstrumentBody(server: server, sleeping: sleeping)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 118)
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
@ViewBuilder fileprivate func metricsPlainScrollEdges(_ edges: Edge.Set = .bottom) -> some View {
|
|
if #available(macOS 26.0, *) { self.scrollEdgeEffectHidden(true, for: edges) } else { self }
|
|
}
|
|
}
|