"?"-Hilfe-Buttons in allen 6 Haupt-Tabs, allen 6 Wizard-Schritten und allen 45 Experte-Menüs öffnen ein Handbuch-Fenster (WKWebView) und springen per Textanker direkt zur passenden Manual-Stelle. build-manual.py generalisiert auf beliebig viele Sprachen (LANGUAGES- Dict) statt hart DE/EN. Manual.en.md: komplette Handübersetzung aller Fließtext-Kapitel. Kapitel 5 (Experte-Referenz) wird pro Sprache automatisch übersetzt, indem L10n.swifts eigenes App-Übersetzungs- Dictionary wiederverwendet wird (714 Einträge geparst) statt einer zweiten, separat gepflegten Übersetzung. Live bestätigt (DE und EN). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
644 lines
30 KiB
Swift
644 lines
30 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"
|
|
@AppStorage(AppPreferences.colorThemeKey) private var colorThemeRaw: String = ColorTheme.standard.rawValue
|
|
@AppStorage(AppPreferences.lanScannerSparklineWidthKey) private var sparklineWidth: Double = 200
|
|
private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .standard }
|
|
|
|
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)).appFont(.caption).foregroundStyle(.secondary)
|
|
} else {
|
|
DeviceColumnHeader(appLanguage: appLanguage)
|
|
ForEach(Array(group.devices.enumerated()), id: \.element.id) { index, device in
|
|
HStack(spacing: 4) {
|
|
DeviceRow(device: device, appLanguage: appLanguage, theme: colorTheme)
|
|
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))
|
|
}
|
|
.listRowBackground(TableZebra.color(for: index))
|
|
}
|
|
}
|
|
} header: {
|
|
HStack(spacing: 8) {
|
|
Text("\(group.title) (\(group.devices.count))")
|
|
if let traffic = viewModel.portTraffic[group.id] {
|
|
Spacer()
|
|
TrafficSparkline(history: viewModel.portTrafficHistory[group.id] ?? [], width: sparklineWidth)
|
|
Label(
|
|
Self.formatTraffic(traffic),
|
|
systemImage: traffic.isActive ? "arrow.up.arrow.down.circle.fill" : "arrow.up.arrow.down.circle"
|
|
)
|
|
.appFont(.caption2)
|
|
.foregroundStyle(traffic.isActive ? colorTheme.trafficActive : .secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
// `.formStyle(.grouped)` paints its own opaque background over each
|
|
// Section's rows — `.listRowBackground` (Zebra-Streifen below) was applied
|
|
// correctly but rendered invisible underneath it. Confirmed live ("keine
|
|
// Änderungen erkennbar"): this is the documented fix, not a bug in the color
|
|
// computation itself.
|
|
.scrollContentBackground(.hidden)
|
|
}
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
if viewModel.isRunningNetworkTool {
|
|
HStack(spacing: 6) {
|
|
ProgressView().controlSize(.small)
|
|
Text(L10n.t("Führe Netzwerk-Test aus…", appLanguage)).appFont(.caption)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(.regularMaterial, in: Capsule())
|
|
.padding(.bottom, 12)
|
|
}
|
|
}
|
|
.navigationTitle(LocalizedStringKey(L10n.t("LAN-Scanner", appLanguage)))
|
|
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabDevices) } }
|
|
.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, theme: colorTheme, rawFieldsDevice: $rawFieldsDevice)
|
|
}
|
|
}
|
|
|
|
/// Fixed MB/s (megabytes/second, not auto-scaling kbps/Mbps/Gbps) per explicit request —
|
|
/// bits-per-second from RouterOS divided by 8 (bytes) then by 1,000,000 (MB).
|
|
private static func formatTraffic(_ traffic: InterfaceTraffic) -> String {
|
|
"↓\(formatMegabytesPerSecond(traffic.rxBitsPerSecond)) ↑\(formatMegabytesPerSecond(traffic.txBitsPerSecond))"
|
|
}
|
|
|
|
private static func formatMegabytesPerSecond(_ bitsPerSecond: Int) -> String {
|
|
String(format: "%.2fMB/s", Double(bitsPerSecond) / 8 / 1_000_000)
|
|
}
|
|
|
|
@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, theme: ColorTheme, 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, theme: theme) { 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-30-seconds rolling window from
|
|
/// `DevicesViewModel.portTrafficHistory` (window widened from the original 10s per explicit
|
|
/// follow-up request). 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]
|
|
let width: Double
|
|
|
|
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: width, 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
|
|
let theme: ColorTheme
|
|
|
|
/// 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 ? theme.staticLease : theme.dynamicLease
|
|
}
|
|
|
|
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)")
|
|
.appFont(.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))
|
|
.appFont(.caption)
|
|
.foregroundStyle(.secondary)
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ForEach(Array(device.rawFields.enumerated()), id: \.element.key) { index, pair in
|
|
HStack(alignment: .top) {
|
|
Text(pair.key).appFont(.caption, design: .monospaced).foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(pair.value).appFont(.caption, design: .monospaced).multilineTextAlignment(.trailing)
|
|
}
|
|
.padding(.vertical, 2)
|
|
.background(TableZebra.color(for: index))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.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)
|
|
.appFont(.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))
|
|
.appFont(.caption)
|
|
.foregroundStyle(.secondary)
|
|
ScrollView {
|
|
Text(result.output.isEmpty ? L10n.t("(keine Ausgabe)", appLanguage) : result.output)
|
|
.appFont(.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 theme: ColorTheme
|
|
let onClose: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Text(L10n.t("Port-Scan:", appLanguage) + " \(result.deviceLabel)")
|
|
.appFont(.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))
|
|
.appFont(.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, theme: theme)
|
|
.background(TableZebra.color(for: index))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.frame(minWidth: 420, minHeight: 420)
|
|
}
|
|
}
|
|
|
|
private struct PortScanRow: View {
|
|
let entry: PortScanResult.Entry
|
|
let appLanguage: String
|
|
let theme: ColorTheme
|
|
|
|
private var color: Color {
|
|
switch entry.status {
|
|
case .open: return theme.portOpen
|
|
case .closed: return theme.portClosed
|
|
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)")
|
|
.appFont(.caption, design: .monospaced)
|
|
.frame(width: 50, alignment: .leading)
|
|
Text(entry.serviceName ?? "-")
|
|
.appFont(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 140, alignment: .leading)
|
|
Spacer()
|
|
Text(statusText)
|
|
.appFont(.caption)
|
|
.foregroundStyle(color)
|
|
}
|
|
.padding(.vertical, 3)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
DevicesView(connectionService: ConnectionService())
|
|
}
|