Klick auf einen Knoten in der Übersicht öffnet jetzt ein schwebendes Popup mit der kompletten verbundenen Kette (neu OverviewGraph. connectedChain, volle transitive Hülle unabhängig vom Knotentyp, bewusst getrennt von der bestehenden highlightedNodeIDs), sauber im selben Spalten-Layout neu angeordnet, Rest des Diagramms abgedunkelt. Nicht-modales Overlay statt .sheet, damit die rechte Seitenleiste währenddessen bedienbar bleibt (Knoten direkt aus dem Popup heraus bearbeitbar). Popup-Größe passt sich automatisch dem Inhalt an, keine Scrollbalken. Der dabei entstandene Close-Button-Header (Titel + Spacer + X, fest oben, Divider direkt darunter) wurde auf alle vier Popup-Formulare der App vereinheitlicht: Experte-Bearbeiten-Sheet und die drei Devices-Sheets (Rohdaten, Netzwerk-Test, Port-Scan) — dort ersetzt er jeweils den bisherigen einzelnen "Schließen"-Button unten. Mehrere Design-Iterationen live mit dem User durchgespielt (Trennlinie im Canvas → separates Panel → Popup → .sheet → non-modales Overlay), finale Version live bestätigt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
633 lines
28 KiB
Swift
633 lines
28 KiB
Swift
import Charts
|
|
import SwiftUI
|
|
|
|
/// "LAN-Scanner" tab: one table per physical Ethernet/WLAN port, each listing the
|
|
/// devices resolved onto it (name/IP/MAC/status), plus an "Aktionen" menu button per row to give
|
|
/// a device a permanent static IP (Winbox's "Make Static") or remove that reservation again.
|
|
struct DevicesView: View {
|
|
@ObservedObject var connectionService: ConnectionService
|
|
@StateObject private var viewModel: DevicesViewModel
|
|
@State private var rawFieldsDevice: LanDevice?
|
|
/// Plain, independent Bool — deliberately NOT derived from `pendingStaticAssignment`.
|
|
/// SwiftUI's `.confirmationDialog` calls the `isPresented` binding's setter with `false` on
|
|
/// EVERY dismissal, including tapping "Zuweisen" itself — a computed binding that reacted to
|
|
/// that by nil-ing `pendingStaticAssignment` was found to clear it before
|
|
/// `confirmStaticAssignment()` could read it, so the confirmed action silently ran on `nil`
|
|
/// and did nothing. Matches this app's own established, working pattern for confirmation
|
|
/// dialogs elsewhere (BackupListView's factory-reset dialog): the dialog's own visibility and
|
|
/// its payload are two separate pieces of state.
|
|
@State private var showStaticConfirmation = false
|
|
/// Same reasoning as `showStaticConfirmation` — independent of `pendingStaticRemoval`.
|
|
@State private var showRemovalConfirmation = false
|
|
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
|
|
|
init(connectionService: ConnectionService) {
|
|
self.connectionService = connectionService
|
|
_viewModel = StateObject(wrappedValue: DevicesViewModel(connectionService: connectionService))
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if connectionService.credentials == nil {
|
|
ContentUnavailableView(
|
|
LocalizedStringKey(L10n.t("Nicht verbunden", appLanguage)),
|
|
systemImage: "network.slash",
|
|
description: Text(L10n.t("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.", appLanguage))
|
|
)
|
|
} else if viewModel.isLoading && viewModel.devices.isEmpty {
|
|
ProgressView(L10n.t("Suche Geräte im Netz…", appLanguage))
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let error = viewModel.loadError, viewModel.devices.isEmpty {
|
|
ContentUnavailableView(
|
|
L10n.t("Konnte Geräte nicht laden", appLanguage), systemImage: "exclamationmark.triangle",
|
|
description: Text(error)
|
|
)
|
|
} else if viewModel.portGroups.isEmpty {
|
|
ContentUnavailableView(
|
|
L10n.t("Keine Ports gefunden", appLanguage), systemImage: "wifi.slash",
|
|
description: Text(L10n.t("Der Router meldet aktuell keine physischen Interfaces.", appLanguage))
|
|
)
|
|
} else {
|
|
Form {
|
|
ForEach(viewModel.portGroups) { group in
|
|
Section {
|
|
if group.devices.isEmpty {
|
|
Text(L10n.t("Keine Geräte", appLanguage)).font(.caption).foregroundStyle(.secondary)
|
|
} else {
|
|
DeviceColumnHeader(appLanguage: appLanguage)
|
|
ForEach(group.devices) { device in
|
|
HStack(spacing: 4) {
|
|
DeviceRow(device: device, appLanguage: appLanguage)
|
|
Spacer()
|
|
// Per explicit request: right-click alone wasn't
|
|
// discoverable ("nicht eindeutig erkennbar oder
|
|
// intuitiv") — every action that used to live only in
|
|
// `.contextMenu` now lives in this visible button
|
|
// instead, same `deviceMenu(for:)` content reused.
|
|
Menu {
|
|
deviceMenu(for: device)
|
|
} label: {
|
|
Label(L10n.t("Aktionen", appLanguage), systemImage: "ellipsis.circle")
|
|
.labelStyle(.iconOnly)
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
.fixedSize()
|
|
.help(L10n.t("Aktionen", appLanguage))
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
HStack(spacing: 8) {
|
|
Text("\(group.title) (\(group.devices.count))")
|
|
if let traffic = viewModel.portTraffic[group.id] {
|
|
Spacer()
|
|
TrafficSparkline(history: viewModel.portTrafficHistory[group.id] ?? [])
|
|
Label(
|
|
Self.formatTraffic(traffic),
|
|
systemImage: traffic.isActive ? "arrow.up.arrow.down.circle.fill" : "arrow.up.arrow.down.circle"
|
|
)
|
|
.font(.caption2)
|
|
.foregroundStyle(traffic.isActive ? .green : .secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
}
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
if viewModel.isRunningNetworkTool {
|
|
HStack(spacing: 6) {
|
|
ProgressView().controlSize(.small)
|
|
Text(L10n.t("Führe Netzwerk-Test aus…", appLanguage)).font(.caption)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(.regularMaterial, in: Capsule())
|
|
.padding(.bottom, 12)
|
|
}
|
|
}
|
|
.navigationTitle(LocalizedStringKey(L10n.t("LAN-Scanner", appLanguage)))
|
|
.toolbar {
|
|
ToolbarItem {
|
|
Button {
|
|
Task { await viewModel.load() }
|
|
} label: {
|
|
if viewModel.isLoading {
|
|
ProgressView().controlSize(.small)
|
|
} else {
|
|
Label(L10n.t("Neu scannen", appLanguage), systemImage: "arrow.clockwise")
|
|
}
|
|
}
|
|
// Per explicit request: a plain icon+text toolbar button up in the corner got
|
|
// overlooked ("wird übersehen") — `.borderedProminent` gives it a filled,
|
|
// colored background so it visually stands out from the window chrome instead
|
|
// of blending into it.
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(viewModel.isLoading)
|
|
}
|
|
}
|
|
.task {
|
|
if viewModel.devices.isEmpty {
|
|
await viewModel.load()
|
|
}
|
|
}
|
|
.onAppear {
|
|
if let credentials = connectionService.credentials {
|
|
viewModel.startTrafficPolling(credentials: credentials)
|
|
}
|
|
}
|
|
.onChange(of: connectionService.state) { _, newState in
|
|
if case .connected = newState, let credentials = connectionService.credentials {
|
|
viewModel.startTrafficPolling(credentials: credentials)
|
|
} else {
|
|
viewModel.stopTrafficPolling()
|
|
}
|
|
}
|
|
.withStaticAssignmentDialogs(
|
|
viewModel: viewModel,
|
|
appLanguage: appLanguage,
|
|
showStaticConfirmation: $showStaticConfirmation,
|
|
showRemovalConfirmation: $showRemovalConfirmation
|
|
)
|
|
.withDeviceDetailSheets(viewModel: viewModel, appLanguage: appLanguage, rawFieldsDevice: $rawFieldsDevice)
|
|
}
|
|
}
|
|
|
|
/// Same unit-suffix style RouterOS itself reports live traffic in (`SSHTransport.
|
|
/// parseBitsPerSecond`'s doc comment: "50.7kbps", "34.0kbps") — kept consistent instead of
|
|
/// inventing a different display format for the same underlying number.
|
|
private static func formatTraffic(_ traffic: InterfaceTraffic) -> String {
|
|
"↓\(formatBitsPerSecond(traffic.rxBitsPerSecond)) ↑\(formatBitsPerSecond(traffic.txBitsPerSecond))"
|
|
}
|
|
|
|
private static func formatBitsPerSecond(_ bitsPerSecond: Int) -> String {
|
|
let units: [(suffix: String, divisor: Double)] = [
|
|
("Gbps", 1_000_000_000), ("Mbps", 1_000_000), ("kbps", 1_000)
|
|
]
|
|
for unit in units where Double(bitsPerSecond) >= unit.divisor {
|
|
return String(format: "%.1f%@", Double(bitsPerSecond) / unit.divisor, unit.suffix)
|
|
}
|
|
return "\(bitsPerSecond)bps"
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func deviceMenu(for device: LanDevice) -> some View {
|
|
if device.hasLease {
|
|
if device.isStatic {
|
|
Button {
|
|
viewModel.pendingStaticRemoval = device
|
|
showRemovalConfirmation = true
|
|
} label: {
|
|
Label(L10n.t("Feste Zuweisung entfernen", appLanguage), systemImage: "pin.slash")
|
|
}
|
|
} else {
|
|
Button {
|
|
viewModel.pendingStaticAssignment = device
|
|
showStaticConfirmation = true
|
|
} label: {
|
|
Label(L10n.t("Feste IP zuweisen", appLanguage), systemImage: "pin.fill")
|
|
}
|
|
}
|
|
} else {
|
|
Text(L10n.t("Kein DHCP-Lease — feste Zuweisung hier nicht möglich", appLanguage))
|
|
}
|
|
Divider()
|
|
Menu {
|
|
Button {
|
|
viewModel.runPing(for: device)
|
|
} label: {
|
|
Label("Ping", systemImage: "dot.radiowaves.left.and.right")
|
|
}
|
|
Button {
|
|
viewModel.runTraceroute(for: device)
|
|
} label: {
|
|
Label("Traceroute", systemImage: "point.topleft.down.curvedto.point.bottomright.up")
|
|
}
|
|
if let hostName = device.hostName, !hostName.isEmpty {
|
|
Button {
|
|
viewModel.runDnsLookup(for: device)
|
|
} label: {
|
|
Label(L10n.t("DNS-Auflösung (nslookup)", appLanguage), systemImage: "magnifyingglass")
|
|
}
|
|
}
|
|
Divider()
|
|
Button {
|
|
viewModel.runPortScan(for: device)
|
|
} label: {
|
|
Label(L10n.t("Port-Scan", appLanguage), systemImage: "list.bullet.rectangle.portrait")
|
|
}
|
|
} label: {
|
|
Label(L10n.t("Netzwerk-Tools", appLanguage), systemImage: "stethoscope")
|
|
}
|
|
.disabled(viewModel.isRunningNetworkTool)
|
|
Divider()
|
|
Button {
|
|
rawFieldsDevice = device
|
|
} label: {
|
|
Label(L10n.t("Rohdaten anzeigen", appLanguage), systemImage: "list.bullet.rectangle")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Split out of the dialog `message:` closures below — same Bug 19 reasoning, but this time it's
|
|
/// a single long string built from many `+`-chained `L10n.t(...)` calls (not a modifier chain)
|
|
/// that timed out the type-checker. A plain function with an explicit `-> String` return type
|
|
/// checks each piece independently instead of inferring the whole chained expression at once.
|
|
private func staticAssignmentMessage(device: LanDevice, appLanguage: String, cliLine: String?) -> String {
|
|
"\(device.ipAddress) " + L10n.t("wird dauerhaft an", appLanguage) + " \(device.macAddress)"
|
|
+ (device.hostName.map { " (\($0))" } ?? "")
|
|
+ L10n.t(" gebunden — die Adresse ändert sich für dieses Gerät nicht mehr automatisch.", appLanguage)
|
|
+ L10n.t("\n\nRückgängig machen: hier im LAN-Scanner-Tab über den \"Aktionen\"-Button beim Gerät", appLanguage)
|
|
+ L10n.t(" → \"Feste Zuweisung entfernen\". Das Gerät bekommt danach aber nicht sofort", appLanguage)
|
|
+ L10n.t(" automatisch eine neue Adresse — dafür muss es die Verbindung kurz trennen und", appLanguage)
|
|
+ L10n.t(" neu aufbauen (Netzwerkkabel kurz ziehen/stecken, WLAN aus/an, oder neu starten).", appLanguage)
|
|
+ L10n.t("\n\nBefehl:", appLanguage) + " \(cliLine ?? "")"
|
|
}
|
|
|
|
private func staticRemovalMessage(device: LanDevice, appLanguage: String, cliLine: String?) -> String {
|
|
L10n.t("Die feste Zuweisung für", appLanguage) + " \(device.macAddress)"
|
|
+ (device.hostName.map { " (\($0))" } ?? "")
|
|
+ L10n.t(" wird entfernt. RouterOS kennt keine direkte Umkehrung von \"Make Static\" —", appLanguage)
|
|
+ L10n.t(" das Gerät bekommt erst beim nächsten Verbindungsaufbau (Kabel/WLAN neu", appLanguage)
|
|
+ L10n.t(" verbinden, Neustart) automatisch wieder eine Adresse per DHCP, eventuell", appLanguage)
|
|
+ L10n.t(" eine andere IP als bisher.", appLanguage)
|
|
+ L10n.t("\n\nBefehl:", appLanguage) + " \(cliLine ?? "")"
|
|
}
|
|
|
|
/// Split out of `body` — same reasoning as `ConnectView`'s dialog-splitting extensions (Bug 19 in
|
|
/// HANDOFF.md): too many `.alert`/`.confirmationDialog`/`.sheet` modifiers chained onto one view
|
|
/// times out the Swift type-checker with a misleading error location, not an actual logic issue.
|
|
private extension View {
|
|
func withStaticAssignmentDialogs(
|
|
viewModel: DevicesViewModel,
|
|
appLanguage: String,
|
|
showStaticConfirmation: Binding<Bool>,
|
|
showRemovalConfirmation: Binding<Bool>
|
|
) -> some View {
|
|
self
|
|
.confirmationDialog(
|
|
L10n.t("Feste IP-Adresse zuweisen?", appLanguage),
|
|
isPresented: showStaticConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(L10n.t("Zuweisen", appLanguage)) {
|
|
Task { await viewModel.confirmStaticAssignment() }
|
|
}
|
|
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) { viewModel.cancelStaticAssignment() }
|
|
} message: {
|
|
if let device = viewModel.pendingStaticAssignment {
|
|
Text(staticAssignmentMessage(device: device, appLanguage: appLanguage, cliLine: viewModel.pendingCommand?.cliLine))
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
L10n.t("Feste Zuweisung entfernen?", appLanguage),
|
|
isPresented: showRemovalConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(L10n.t("Entfernen", appLanguage), role: .destructive) {
|
|
Task { await viewModel.confirmStaticRemoval() }
|
|
}
|
|
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) { viewModel.cancelStaticRemoval() }
|
|
} message: {
|
|
if let device = viewModel.pendingStaticRemoval {
|
|
Text(staticRemovalMessage(device: device, appLanguage: appLanguage, cliLine: viewModel.pendingRemovalCommand?.cliLine))
|
|
}
|
|
}
|
|
.alert(
|
|
L10n.t("Fehler", appLanguage),
|
|
isPresented: Binding(
|
|
get: { viewModel.applyError != nil },
|
|
set: { if !$0 { viewModel.dismissApplyError() } }
|
|
),
|
|
presenting: viewModel.applyError
|
|
) { _ in
|
|
Button(L10n.t("OK", appLanguage)) {}
|
|
} message: { error in
|
|
Text(error)
|
|
}
|
|
}
|
|
|
|
func withDeviceDetailSheets(viewModel: DevicesViewModel, appLanguage: String, rawFieldsDevice: Binding<LanDevice?>) -> some View {
|
|
self
|
|
.sheet(item: rawFieldsDevice) { device in
|
|
RawFieldsSheet(device: device, appLanguage: appLanguage) { rawFieldsDevice.wrappedValue = nil }
|
|
}
|
|
.sheet(item: Binding(
|
|
get: { viewModel.networkToolResult },
|
|
set: { if $0 == nil { viewModel.networkToolResult = nil } }
|
|
)) { result in
|
|
NetworkToolResultSheet(result: result, appLanguage: appLanguage) { viewModel.networkToolResult = nil }
|
|
}
|
|
.sheet(item: Binding(
|
|
get: { viewModel.portScanResult },
|
|
set: { if $0 == nil { viewModel.portScanResult = nil } }
|
|
)) { result in
|
|
PortScanResultSheet(result: result, appLanguage: appLanguage) { viewModel.portScanResult = nil }
|
|
}
|
|
.alert(
|
|
L10n.t("Netzwerk-Test fehlgeschlagen", appLanguage),
|
|
isPresented: Binding(
|
|
get: { viewModel.networkToolError != nil },
|
|
set: { if !$0 { viewModel.dismissNetworkToolError() } }
|
|
),
|
|
presenting: viewModel.networkToolError
|
|
) { _ in
|
|
Button(L10n.t("OK", appLanguage)) {}
|
|
} message: { error in
|
|
Text(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Small sparkline next to each port header — last-10-seconds rolling window from
|
|
/// `DevicesViewModel.portTrafficHistory` (Nutzerwunsch: "ein kleines Liniendiagramm der letzten
|
|
/// 10 Sekunden ... pro Port"). No axes/labels by design — a trend glance, not a readable chart;
|
|
/// the exact current numbers are already shown right next to it via the ↓/↑ text. Stays an empty
|
|
/// fixed-size placeholder (not collapsing/disappearing) with fewer than two points, so the row
|
|
/// layout doesn't jump around during the first couple of poll ticks after opening the tab.
|
|
private struct TrafficSparkline: View {
|
|
let history: [TrafficSample]
|
|
|
|
var body: some View {
|
|
Group {
|
|
if history.count >= 2 {
|
|
Chart(history) { sample in
|
|
LineMark(
|
|
x: .value("Zeit", sample.timestamp),
|
|
y: .value("Traffic", sample.totalBitsPerSecond)
|
|
)
|
|
.interpolationMethod(.linear)
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
.chartXAxis(.hidden)
|
|
.chartYAxis(.hidden)
|
|
.chartYScale(domain: 0...max(history.map(\.totalBitsPerSecond).max() ?? 1, 1))
|
|
} else {
|
|
Color.clear
|
|
}
|
|
}
|
|
.frame(width: 50, height: 16)
|
|
}
|
|
}
|
|
|
|
/// Column widths shared between the header and each row so they line up like a real table.
|
|
private enum DeviceColumn {
|
|
static let icon: CGFloat = 16
|
|
static let name: CGFloat = 130
|
|
static let ip: CGFloat = 110
|
|
static let mac: CGFloat = 140
|
|
static let status: CGFloat = 80
|
|
}
|
|
|
|
private struct DeviceColumnHeader: View {
|
|
let appLanguage: String
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
Spacer().frame(width: DeviceColumn.icon)
|
|
Text(L10n.t("Name", appLanguage)).frame(width: DeviceColumn.name, alignment: .leading)
|
|
Text(L10n.t("IP-Adresse", appLanguage)).frame(width: DeviceColumn.ip, alignment: .leading)
|
|
Text(L10n.t("MAC-Adresse", appLanguage)).frame(width: DeviceColumn.mac, alignment: .leading)
|
|
Text(L10n.t("Status", appLanguage)).frame(width: DeviceColumn.status, alignment: .leading)
|
|
Spacer()
|
|
}
|
|
.font(.caption2.bold())
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private struct DeviceRow: View {
|
|
let device: LanDevice
|
|
let appLanguage: String
|
|
|
|
/// Three distinct states, not two — a device with no matching DHCP lease at all is neither
|
|
/// "Fest" (a real static reservation) nor "Dynamisch" (a real active lease); conflating it
|
|
/// with "Fest" previously mislabeled every ARP-only device, DHCP clients included whenever
|
|
/// their lease didn't get matched.
|
|
private var statusIcon: String {
|
|
guard device.hasLease else { return "questionmark.circle" }
|
|
return device.isStatic ? "pin.fill" : "circle.fill"
|
|
}
|
|
|
|
private var statusColor: Color {
|
|
guard device.hasLease else { return .secondary }
|
|
return device.isStatic ? .orange : .green
|
|
}
|
|
|
|
private var statusText: String {
|
|
guard device.hasLease else { return L10n.t("Kein DHCP", appLanguage) }
|
|
return device.isStatic ? L10n.t("Fest", appLanguage) : L10n.t("Dynamisch", appLanguage)
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: statusIcon)
|
|
.foregroundStyle(statusColor)
|
|
.font(.system(size: 8))
|
|
.frame(width: DeviceColumn.icon)
|
|
.help(statusText)
|
|
Text(device.hostName?.isEmpty == false ? device.hostName! : "-")
|
|
.font(.system(size: 12))
|
|
.lineLimit(1)
|
|
.frame(width: DeviceColumn.name, alignment: .leading)
|
|
Text(device.ipAddress)
|
|
.font(.system(size: 12))
|
|
.frame(width: DeviceColumn.ip, alignment: .leading)
|
|
Text(device.macAddress)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.frame(width: DeviceColumn.mac, alignment: .leading)
|
|
Text(statusText)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: DeviceColumn.status, alignment: .leading)
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 1)
|
|
}
|
|
}
|
|
|
|
private struct RawFieldsSheet: View {
|
|
let device: LanDevice
|
|
let appLanguage: String
|
|
let onClose: () -> Void
|
|
|
|
var body: some View {
|
|
// Same header layout/behavior as every other popup's close button (see `OverviewView
|
|
// .focusPanel`): fixed title + spacer + `xmark.circle.fill`, `.padding(10)`, `Divider()`
|
|
// directly below, separate from the scrollable content's own padding.
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Text(L10n.t("Rohdaten:", appLanguage) + " \(device.macAddress)")
|
|
.font(.headline)
|
|
Spacer()
|
|
Button {
|
|
onClose()
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(L10n.t("Schließen", appLanguage))
|
|
}
|
|
.padding(10)
|
|
|
|
Divider()
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(L10n.t("Alle Felder, die RouterOS für diesen Eintrag zurückgegeben hat — hilfreich, falls Status/Port hier falsch aussieht.", appLanguage))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ForEach(device.rawFields, id: \.key) { pair in
|
|
HStack(alignment: .top) {
|
|
Text(pair.key).font(.caption.monospaced()).foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(pair.value).font(.caption.monospaced()).multilineTextAlignment(.trailing)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.frame(minWidth: 380, minHeight: 300)
|
|
}
|
|
}
|
|
|
|
/// Raw text output of a ping/traceroute/DNS-Auflösung run against a device — see
|
|
/// `NetworkToolResult`'s doc comment for why this isn't parsed into a nicer structured view.
|
|
private struct NetworkToolResultSheet: View {
|
|
let result: NetworkToolResult
|
|
let appLanguage: String
|
|
let onClose: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Text(result.title)
|
|
.font(.headline)
|
|
Spacer()
|
|
Button {
|
|
onClose()
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(L10n.t("Schließen", appLanguage))
|
|
}
|
|
.padding(10)
|
|
|
|
Divider()
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(L10n.t("Ausgeführt vom Router aus (eigene SSH-Verbindung) — testet die Erreichbarkeit vom Router zu diesem Gerät, nicht von diesem Mac.", appLanguage))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
ScrollView {
|
|
Text(result.output.isEmpty ? L10n.t("(keine Ausgabe)", appLanguage) : result.output)
|
|
.font(.system(.caption, design: .monospaced))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.frame(minWidth: 420, minHeight: 320)
|
|
}
|
|
}
|
|
|
|
/// Colored per-port result of a `PortScanner` run — red (open), green (closed), grey
|
|
/// (unreachable/no response), per explicit request. Run directly from this Mac, not the router
|
|
/// (see `PortScanner`'s doc comment), so the caption here says so — different from
|
|
/// `NetworkToolResultSheet`'s "run from the router" caption right above it in this same file.
|
|
private struct PortScanResultSheet: View {
|
|
let result: PortScanResult
|
|
let appLanguage: String
|
|
let onClose: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Text(L10n.t("Port-Scan:", appLanguage) + " \(result.deviceLabel)")
|
|
.font(.headline)
|
|
Spacer()
|
|
Button {
|
|
onClose()
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(L10n.t("Schließen", appLanguage))
|
|
}
|
|
.padding(10)
|
|
|
|
Divider()
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(L10n.t("TCP-Verbindungsversuch auf gängige Ports, ausgeführt von diesem Mac aus (nicht vom Router) — rot = offen, grün = geschlossen (Gerät antwortet, aber nichts lauscht dort), grau = keine Antwort (Firewall, Gerät aus, oder Port gefiltert).", appLanguage))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
ScrollView {
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(result.entries.enumerated()), id: \.element.id) { index, entry in
|
|
if index > 0 { Divider() }
|
|
PortScanRow(entry: entry, appLanguage: appLanguage)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.frame(minWidth: 420, minHeight: 420)
|
|
}
|
|
}
|
|
|
|
private struct PortScanRow: View {
|
|
let entry: PortScanResult.Entry
|
|
let appLanguage: String
|
|
|
|
private var color: Color {
|
|
switch entry.status {
|
|
case .open: return .red
|
|
case .closed: return .green
|
|
case .unreachable: return .secondary
|
|
}
|
|
}
|
|
|
|
private var statusText: String {
|
|
switch entry.status {
|
|
case .open: return L10n.t("Offen", appLanguage)
|
|
case .closed: return L10n.t("Geschlossen", appLanguage)
|
|
case .unreachable: return L10n.t("Keine Antwort", appLanguage)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Circle().fill(color).frame(width: 8, height: 8)
|
|
Text("\(entry.port)")
|
|
.font(.system(.caption, design: .monospaced))
|
|
.frame(width: 50, alignment: .leading)
|
|
Text(entry.serviceName ?? "-")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 140, alignment: .leading)
|
|
Spacer()
|
|
Text(statusText)
|
|
.font(.caption)
|
|
.foregroundStyle(color)
|
|
}
|
|
.padding(.vertical, 3)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
DevicesView(connectionService: ConnectionService())
|
|
}
|