diff --git a/CHANGELOG.md b/CHANGELOG.md index 212440a..2f331dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ 记录 OpenStats 应用的功能新增、样式调整和问题修复,按版本与日期倒序排列。 官网、构建与发布脚本等不影响应用本身的改动不在此记录。 +## 未发布 + +### 2026-11-01 + +#### 新增 + +- 设置新增「监控大屏」:开启后向局域网提供只读网页仪表盘(默认端口 8790,可选 8791/8888),同一网络里的平板、旧电脑、电视用浏览器打开即可实时查看本机监控——CPU(总占用与各核心)、内存、网络速率、磁盘、负载、电量与进程 TOP,配 5 分钟走势,页面自适应横竖屏,断网自动重连。数据只在局域网内流转,不出外网。 + ## 0.7.3 · 2026-10-21 ### 2026-10-21 diff --git a/Packages/OpenStatsKit/Sources/Localization/Translations.swift b/Packages/OpenStatsKit/Sources/Localization/Translations.swift index 08ce14f..c6124d6 100644 --- a/Packages/OpenStatsKit/Sources/Localization/Translations.swift +++ b/Packages/OpenStatsKit/Sources/Localization/Translations.swift @@ -1217,5 +1217,14 @@ macOS 报告的热状态:偏高及以上时,系统会降低性能来降温 T ,充电中 , charging ,已从程序坞移除 , removed from the Dock :{} : {} +监控大屏 Dashboard screen +无法在端口 {} 上监听 Cannot listen on port {} +未知错误 Unknown error +启用监控大屏 Enable dashboard screen +同一局域网里的设备(平板、旧电脑、电视的浏览器)打开下面的地址,即可实时查看本机监控,适合挂一块监控屏 Open the address below in a browser on any device on the same network (tablet, spare computer, TV) for a live view of this Mac — handy as a dedicated monitoring screen +端口 Port +未开启 Off +服务运行中 · 端口 {} Running · port {} +端口 {} 启动失败 Failed to start on port {} """# } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift index e9c2d9e..881b54a 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift @@ -84,6 +84,7 @@ public struct MainWindowView: View { case .startupItems: StartupItemsPage() case .settingsGeneral: SettingsTabPage { GeneralSettings() } case .settingsMenuBar: SettingsTabPage { MenuBarSettings() } + case .settingsDashboard: SettingsTabPage { DashboardSettings() } case .settingsNotifications: SettingsTabPage { NotificationSettings() } case .settingsAccount: SettingsTabPage { AccountSettings() } case .settingsHelper: SettingsTabPage { HelperSettings() } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/DashboardSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/DashboardSettings.swift new file mode 100644 index 0000000..d929474 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/DashboardSettings.swift @@ -0,0 +1,64 @@ +import Localization +import SwiftUI + +/// 设置 · 监控大屏:开关、端口与当前地址 +struct DashboardSettings: View { + @Environment(AppModel.self) private var model + + var body: some View { + @Bindable var settings = model.settings + + SettingsGroup { + GroupRow(showsDivider: false) { + SettingRow(title: tr("启用监控大屏"), subtitle: tr("同一局域网里的设备(平板、旧电脑、电视的浏览器)打开下面的地址,即可实时查看本机监控,适合挂一块监控屏")) { + DSToggle(isOn: Binding(get: { settings.dashboardEnabled }, + set: { + settings.dashboardEnabled = $0 + model.dashboard.applySettings() + }), + label: tr("启用监控大屏")) + } + } + GroupRow { + SettingRow(title: tr("端口")) { + SegmentedControl(selection: Binding(get: { settings.dashboardPort }, + set: { + settings.dashboardPort = $0 + model.dashboard.applySettings() + }), + options: AppSettings.dashboardPortOptions.map { ($0, "\($0)") }) + .frame(width: DS.Size.sidebarWidth) + } + } + GroupRow { + status + } + } + } + + @ViewBuilder private var status: some View { + switch model.dashboard.availability { + case .off: + SettingRow(title: tr("状态")) { + Text(tr("未开启")) + .foregroundStyle(DS.Palette.textSecondary) + } + case .starting(let port), .running(let port): + SettingRow(title: tr("状态")) { + VStack(alignment: .trailing, spacing: DS.Space.s1) { + Text(tr("服务运行中 · 端口 \(port)")) + .foregroundStyle(DS.Palette.success) + if let address = model.dashboard.address { + Text(address).font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + } + } + } + case .failed(let port, let reason): + SettingRow(title: tr("状态"), subtitle: reason) { + Text(tr("端口 \(port) 启动失败")) + .foregroundStyle(DS.Palette.error) + } + } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index 0a802ab..d8bc0ec 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -30,6 +30,7 @@ public final class AppModel { public let history: HistoryRecorder public let sync: SyncController let diskTools: DiskToolsController + public let dashboard: DashboardController @ObservationIgnored public let hub: MetricsHub public var isMainWindowVisible = false @@ -84,6 +85,7 @@ public final class AppModel { sync = SyncController(settings: settings) diskTools = DiskToolsController(helper: helper) speedTest = SpeedTestController(settings: settings) + dashboard = DashboardController(settings: settings, store: store) } /// 根据当前可见内容决定采集范围:主窗口看标签页,详情弹窗看是哪一项 @@ -103,13 +105,15 @@ public final class AppModel { demand.memory = true demand.network = true let showing = { (page: PanelTab, item: MenuBarItem) in (window && tab == page) || popover == item } + // 监控大屏开着时,它的页面数据要一直采 + let dashboard = settings.dashboardEnabled demand.gpu = overview || (window && tab == .system) || menu.contains(.gpu) || showing(.gpu, .gpu) - demand.disk = overview || (window && [.system, .cleaner, .disk].contains(tab)) || menu.contains(.disk) || popover == .disk + demand.disk = overview || (window && [.system, .cleaner, .disk].contains(tab)) || menu.contains(.disk) || popover == .disk || dashboard demand.diskDetail = showing(.disk, .disk) demand.battery = overview || (window && tab == .keepAwake) || keepAwake.lidClosedActive - || menu.contains(.battery) || showing(.battery, .battery) + || menu.contains(.battery) || showing(.battery, .battery) || dashboard demand.processes = overview || (window && [.processes, .disk].contains(tab)) || showing(.cpu, .cpu) || showing(.memory, .memory) - || popover == .disk + || popover == .disk || dashboard demand.systemProcesses = window && tab == .processes var groups = Set() diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index 80d0da6..e25b232 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -281,13 +281,13 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable { /// 主窗口侧边栏的页面 public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case overview, system, history, cpu, gpu, memory, disk, network, thermal, battery, processes, keepAwake, cleaner, uninstaller, startupItems - case settingsGeneral, settingsMenuBar, settingsNotifications, settingsAccount, settingsHelper, settingsAbout + case settingsGeneral, settingsMenuBar, settingsDashboard, settingsNotifications, settingsAccount, settingsHelper, settingsAbout public var id: String { rawValue } static let monitors: [PanelTab] = [.overview, .system, .history, .cpu, .gpu, .memory, .disk, .network, .thermal, .battery] static let tools: [PanelTab] = [.processes, .startupItems, .keepAwake, .cleaner, .uninstaller] - static let settings: [PanelTab] = [.settingsGeneral, .settingsMenuBar, .settingsNotifications, .settingsAccount, .settingsHelper, .settingsAbout] + static let settings: [PanelTab] = [.settingsGeneral, .settingsMenuBar, .settingsDashboard, .settingsNotifications, .settingsAccount, .settingsHelper, .settingsAbout] var isSettings: Bool { Self.settings.contains(self) } @@ -313,6 +313,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case .startupItems: tr("启动项") case .settingsGeneral: tr("通用") case .settingsMenuBar: tr("菜单栏") + case .settingsDashboard: tr("监控大屏") case .settingsNotifications: tr("通知") case .settingsAccount: tr("账号与同步") case .settingsHelper: tr("辅助工具") @@ -339,6 +340,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case .startupItems: "power" case .settingsGeneral: "gearshape" case .settingsMenuBar: "menubar.rectangle" + case .settingsDashboard: "tv" case .settingsNotifications: "bell.badge" case .settingsAccount: "person.crop.circle" case .settingsHelper: "lock.shield" @@ -523,6 +525,14 @@ public final class AppSettings { public var cleanPrefersTrash: Bool { didSet { defaults.set(cleanPrefersTrash, forKey: Keys.cleanPrefersTrash) } } + /// 局域网监控大屏:向同网段设备提供只读网页仪表盘 + public var dashboardEnabled: Bool { + didSet { defaults.set(dashboardEnabled, forKey: Keys.dashboardEnabled) } + } + /// 监控大屏的 HTTP 端口 + public var dashboardPort: Int { + didSet { defaults.set(dashboardPort, forKey: Keys.dashboardPort) } + } public static let refreshOptions = [1, 2, 3, 5] public static let batteryFloorOptions = [10, 20, 30, 40] @@ -531,6 +541,7 @@ public final class AppSettings { public static let alertTemperatureOptions = [85, 90, 95, 100] public static let alertLoadOptions = [70, 80, 90] public static let cpuChartOptions = [60, 180, 300] + public static let dashboardPortOptions = [8790, 8791, 8888] public init(defaults: UserDefaults = .standard) { self.defaults = defaults @@ -585,6 +596,9 @@ public final class AppSettings { ? defaults.integer(forKey: Keys.alertCPULoad) : 80 cpuChartSeconds = Self.cpuChartOptions.contains(defaults.integer(forKey: Keys.cpuChartSeconds)) ? defaults.integer(forKey: Keys.cpuChartSeconds) : 60 + dashboardEnabled = defaults.object(forKey: Keys.dashboardEnabled) as? Bool ?? false + dashboardPort = Self.dashboardPortOptions.contains(defaults.integer(forKey: Keys.dashboardPort)) + ? defaults.integer(forKey: Keys.dashboardPort) : 8790 } /// 合并显示用自定义顺序;每项独立的位置由 macOS 的 ⌘ 拖动与 autosaveName 记住。 @@ -680,5 +694,7 @@ public final class AppSettings { static let alertCPUTemperature = "alertCPUTemperature" static let alertCPULoad = "alertCPULoad" static let cpuChartSeconds = "cpuChartSeconds" + static let dashboardEnabled = "dashboardEnabled" + static let dashboardPort = "dashboardPort" } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/DashboardController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/DashboardController.swift new file mode 100644 index 0000000..aad9b86 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/DashboardController.swift @@ -0,0 +1,198 @@ +import AppKit +import Foundation +import Localization +import Metrics +import Observation + +/// 线程间传递最新 /metrics 内容的小盒子 +final class DashboardPayloadBox: @unchecked Sendable { + private let lock = NSLock() + private var stored = Data("{}".utf8) + + var value: Data { + lock.lock() + defer { lock.unlock() } + return stored + } + + func update(_ data: Data) { + lock.lock() + stored = data + lock.unlock() + } +} + +/// 监控大屏:把 MetricsStore 里的读数每 2 秒整理成一份 JSON,经 DashboardServer 发给局域网里的 +/// 任意设备(平板、旧电脑、电视浏览器),配合全屏模式当监控墙用。数据不出局域网 +@MainActor +@Observable +public final class DashboardController { + /// 服务可用性;running 时附带端口 + public enum Availability: Equatable { + case off + case starting(port: Int) + case running(port: Int) + case failed(port: Int, reason: String) + } + + public private(set) var availability: Availability = .off + public static let sampleInterval: TimeInterval = 2 + static let historyPoints = 150 // 150 × 2s = 5 分钟走势 + + private let settings: AppSettings + private let store: MetricsStore + @ObservationIgnored let payloadBox = DashboardPayloadBox() + @ObservationIgnored private let server: DashboardServer + @ObservationIgnored private var sampler: Task? + /// 走势样本:时间、CPU%、内存%、累计下载 / 上传字节(网页端按差值算速率) + struct Sample { + let time: TimeInterval + let cpu: Double + let memory: Double + let totalDown: Double + let totalUp: Double + } + private var samples: [Sample] = [] + private var totalDown: Double = 0 + private var totalUp: Double = 0 + + init(settings: AppSettings, store: MetricsStore) { + self.settings = settings + self.store = store + server = DashboardServer(html: DashboardPage.html) { [payloadBox] in payloadBox.value } + if settings.dashboardEnabled { + applySettings() + } + } + + // MARK: 生命周期 + + /// 开关或端口变化后调用;幂等,当前配置已在跑则不动 + public func applySettings() { + guard settings.dashboardEnabled else { + stop() + return + } + let port = settings.dashboardPort + if case .running(let running) = availability, running == port { return } + availability = .starting(port: port) + server.start(port: UInt16(clamping: port)) + if sampler == nil { + sampler = Task { [weak self] in + while !Task.isCancelled { + let start = Date() + await self?.sampleOnce() + let elapsed = Date().timeIntervalSince(start) + if elapsed < Self.sampleInterval { + try? await Task.sleep(for: .seconds(Self.sampleInterval - elapsed)) + } + } + } + } + } + + public func stop() { + sampler?.cancel() + sampler = nil + server.stop() + availability = .off + } + + /// 展示用地址:优先主网络接口的 IPv4 + public var address: String? { + guard case .running = availability else { return nil } + let host = store.networkInterface?.ipv4 ?? "127.0.0.1" + return "http://\(host):\(settings.dashboardPort)/" + } + + // MARK: 采样 + + func sampleOnce() { + // 监听状态(端口被占用等)在采样循环里轮询,最多晚 2 秒反映到设置页 + if case .starting(let port) = availability, let state = server.currentState() { + availability = state.ready + ? .running(port: port) + : .failed(port: port, reason: state.reason ?? tr("未知错误")) + } + + let now = Date() + let network = store.network + // 网页端的走势按累计字节的差值算速率,这里把瞬时速率积分成累计值 + totalDown += (network?.downloadBytesPerSecond ?? 0) * Self.sampleInterval + totalUp += (network?.uploadBytesPerSecond ?? 0) * Self.sampleInterval + let sample = Sample(time: now.timeIntervalSince1970, + cpu: (store.cpu?.total ?? 0) * 100, + memory: (store.memory?.usedFraction ?? 0) * 100, + totalDown: totalDown, + totalUp: totalUp) + samples.append(sample) + if samples.count > Self.historyPoints { samples.removeFirst(samples.count - Self.historyPoints) } + payloadBox.update(buildPayload(sample: sample)) + } + + /// 组装 /metrics;抽成可单测的纯读取 + func buildPayload(sample: Sample) -> Data { + let cpu = store.cpu + let memory = store.memory + let disk = store.disk + let gigabyte = 1_000_000_000.0 + let gibibyte = 1_073_741_824.0 + + var system: [String: Any] = [ + "time": sample.time, + "hostname": Host.current().localizedName ?? ProcessInfo.processInfo.hostName, + "cpu": sample.cpu, + "cpuPercpu": (cpu?.perCore ?? []).map { ($0 * 100).rounded(.toNearestOrAwayFromZero) }, + "load": (cpu?.loadAverage ?? [0, 0, 0]).map { ($0 * 100).rounded() / 100 }, + "memUsedGB": memory.map { Double($0.used) / gibibyte } ?? 0, + "memTotalGB": memory.map { Double($0.total) / gibibyte } ?? 0, + "memPercent": sample.memory, + "swapPercent": ((memory.flatMap { $0.swapTotal > 0 ? Double($0.swapUsed) / Double($0.swapTotal) * 100 : 0 } ?? 0) * 10).rounded() / 10, + "netDownBps": (store.network?.downloadBytesPerSecond ?? 0).rounded(), + "netUpBps": (store.network?.uploadBytesPerSecond ?? 0).rounded(), + "diskUsedGB": disk.map { (Double($0.used) / gigabyte * 10).rounded() / 10 } ?? 0, + "diskTotalGB": disk.map { (Double($0.total) / gigabyte * 10).rounded() / 10 } ?? 0, + "diskPercent": disk.map { ($0.usedFraction * 1000).rounded() / 10 } ?? 0, + "uptimeSec": store.system.bootDate.map { Date().timeIntervalSince($0).rounded() } ?? 0, + ] + if let battery = store.battery { + system["batteryPercent"] = (battery.level * 100).rounded() + } else { + system["batteryPercent"] = NSNull() + } + system["processes"] = topProcesses() + + let history: [String: Any] = [ + "cpu": samples.map(\.cpu), + "mem": samples.map(\.memory), + "netDown": samples.map(\.totalDown), + "netUp": samples.map(\.totalUp), + "time": samples.map(\.time), + ] + + let payload: [String: Any] = [ + "system": system, + "history": history, + "sampleInterval": Self.sampleInterval, + "serverTime": Date().timeIntervalSince1970, + ] + return (try? JSONSerialization.data(withJSONObject: payload)) ?? Data("{}".utf8) + } + + /// CPU 前六名;CPU 口径与活动监视器一致(单核满载为 100%) + private func topProcesses() -> [[String: Any]] { + let total = Double(store.memory?.total ?? 0) + return store.processes + .sorted { $0.cpu > $1.cpu } + .prefix(6) + .map { process in + [ + "pid": process.pid, + "name": process.name, + "cpu": (process.cpu * 100).rounded() / 100, + "mem": total > 0 ? (Double(process.memory) / total * 100 * 10).rounded() / 10 : 0, + ] as [String: Any] + } + } + +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/DashboardPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/DashboardPage.swift new file mode 100644 index 0000000..c6b280b --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/DashboardPage.swift @@ -0,0 +1,135 @@ +import Foundation + +/// 监控大屏页面:一个自包含的 HTML 文件(无外部依赖),由 DashboardServer 直接下发。 +/// 每 2 秒拉一次 /metrics;断网时保留最后读数并持续重试。数据源没有 Gost 线路时自动收起该卡片。 +/// 布局自适应三种形态:横屏(≥4:3)、竖屏平板(宽≥1000px)、窄屏手机 +enum DashboardPage { + static let html = Data(#""" + + + + + +主机监控 + + + +
+ +
主机监控
等待首次采样
连接中…
+
--:--:--
已运行 —
+
+
+

CPU 使用率 · 0–100%

--
负载 —
5分钟前现在
+

内存 使用率 · 0–100%

--
—
交换 —
5分钟前现在
+

网络 物理接口流量 · 近5分钟

↓ 下载
--B/s
↑ 上传
--B/s
上下行共用刻度峰值 —
+

Gost 线路 代理出口连通性 · 延迟走势

查找中…
—
等待进程及端口发现
+

系统 当前状态

磁盘--
负载--
电池--
进程--
+

进程 TOP 按CPU排序 · 单核口径

进程CPU内存
+
+
OpenStats 监控大屏 · 每 2 秒自动刷新 · 等待数据
+ + + +"""#.utf8) +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/DashboardServer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/DashboardServer.swift new file mode 100644 index 0000000..c74c6c9 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/DashboardServer.swift @@ -0,0 +1,149 @@ +import Foundation +import Localization +import Network + +/// 监控大屏的 HTTP 服务:NWListener 上跑一个极简 HTTP/1.1,一连接一个请求(Connection: close)。 +/// 只服务三个只读端点(页面 / 指标 / 存活),不解析请求体; +/// 页面与指标快照由 DashboardController 提前备好,这里只负责收发。 +/// 监听是否成功不通过回调通知(避免跨线程闭包),控制器在采样循环里读 currentState 轮询 +final class DashboardServer: @unchecked Sendable { + private let lock = NSLock() + private var listener: NWListener? + private var ready = false + private var failureReason: String? + private let html: Data + /// 最新的 /metrics 内容,由控制器的采样循环更新 + private let metricsProvider: @Sendable () -> Data + + init(html: Data, metricsProvider: @escaping @Sendable () -> Data) { + self.html = html + self.metricsProvider = metricsProvider + } + + // MARK: 生命周期 + + /// 开始监听指定端口;端口冲突等异步失败写进 currentState + func start(port: UInt16) { + lock.lock() + listener?.cancel() + listener = nil + ready = false + failureReason = nil + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + guard let listener = try? NWListener(using: params, on: NWEndpoint.Port(rawValue: port) ?? .any) else { + failureReason = tr("无法在端口 \(port) 上监听") + lock.unlock() + return + } + self.listener = listener + lock.unlock() + + listener.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .ready: + self.noteState(ready: true, reason: nil) + case .failed(let error): + self.noteState(ready: false, reason: error.localizedDescription) + default: + break + } + } + listener.newConnectionHandler = { [weak self] connection in + self?.serve(connection) + } + listener.start(queue: .global()) + } + + func stop() { + lock.lock() + listener?.cancel() + listener = nil + ready = false + failureReason = nil + lock.unlock() + } + + /// 最近一次监听状态;未调用过 start 或已停止时为 nil + func currentState() -> (ready: Bool, reason: String?)? { + lock.lock() + defer { lock.unlock() } + guard listener != nil else { return nil } + if ready { return (true, nil) } + if let failureReason { return (false, failureReason) } + return nil // 还在启动中 + } + + private func noteState(ready: Bool, reason: String?) { + lock.lock() + self.ready = ready + self.failureReason = reason + lock.unlock() + } + + // MARK: 连接处理 + + private func serve(_ connection: NWConnection) { + connection.start(queue: .global()) + receiveRequest(connection, buffered: Data()) + } + + private func receiveRequest(_ connection: NWConnection, buffered: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, isComplete, error in + guard let self, error == nil else { + connection.cancel() + return + } + var buffer = buffered + if let data { buffer.append(data) } + // 头没收全就继续收;超过 16KB 或对端写完还没出现头也不再等 + if buffer.range(of: Data("\r\n\r\n".utf8)) == nil, !isComplete, buffer.count < 16_384 { + self.receiveRequest(connection, buffered: buffer) + return + } + let requestLine = String(decoding: buffer.prefix(8192), as: UTF8.self) + .split(separator: "\r\n", maxSplits: 1).first.map(String.init) + let path = requestLine?.split(separator: " ").dropFirst().first.map(String.init) + let response = Self.makeResponse(path: path, html: self.html, metrics: self.metricsProvider()) + connection.send(content: response.data, contentContext: .defaultMessage, isComplete: true, + completion: .contentProcessed { _ in connection.cancel() }) + } + } +} + +/// 一次 HTTP 响应:状态行 + 头 + 正文,已拼好待发送 +struct HTTPResponse { + let status: Int + let contentType: String + let body: Data + + var data: Data { + let reason = Self.reasons[status] ?? "OK" + let head = "HTTP/1.1 \(status) \(reason)\r\n" + + "Content-Type: \(contentType)\r\n" + + "Content-Length: \(body.count)\r\n" + + "Cache-Control: no-store\r\n" + + "Connection: close\r\n\r\n" + return Data(head.utf8) + body + } + + private static let reasons: [Int: String] = [200: "OK", 404: "Not Found"] +} + +extension DashboardServer { + /// 纯函数:按路径生成响应,便于单测 + static func makeResponse(path: String?, html: Data, metrics: Data) -> HTTPResponse { + let path = path?.split(separator: "?").first.map(String.init) ?? "/" + switch path { + case "/", "/index.html": + return HTTPResponse(status: 200, contentType: "text/html; charset=utf-8", body: html) + case "/metrics": + return HTTPResponse(status: 200, contentType: "application/json; charset=utf-8", body: metrics) + case "/healthz": + return HTTPResponse(status: 200, contentType: "text/plain; charset=utf-8", body: Data("ok".utf8)) + default: + return HTTPResponse(status: 404, contentType: "text/plain; charset=utf-8", body: Data("not found".utf8)) + } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift index 13002fa..7054b9a 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift @@ -35,6 +35,8 @@ public struct SettingsDocument: Codable, Equatable, Sendable { public var historyEnabled: Bool? public var autoCheckUpdates: Bool? public var cleanPrefersTrash: Bool? + public var dashboardEnabled: Bool? + public var dashboardPort: Int? public init() {} } @@ -72,6 +74,8 @@ extension AppSettings { doc.historyEnabled = historyEnabled doc.autoCheckUpdates = autoCheckUpdates doc.cleanPrefersTrash = cleanPrefersTrash + doc.dashboardEnabled = dashboardEnabled + doc.dashboardPort = dashboardPort return doc } @@ -123,6 +127,8 @@ extension AppSettings { assign(\.historyEnabled, doc.historyEnabled) assign(\.autoCheckUpdates, doc.autoCheckUpdates) assign(\.cleanPrefersTrash, doc.cleanPrefersTrash) + assign(\.dashboardEnabled, doc.dashboardEnabled) + assign(\.dashboardPort, option(doc.dashboardPort, in: Self.dashboardPortOptions)) } /// 出厂设置对应的文档:用一个空的 defaults 域构造一次即可,不会写盘 diff --git a/Packages/OpenStatsKit/Tests/OpenStatsUITests/DashboardTests.swift b/Packages/OpenStatsKit/Tests/OpenStatsUITests/DashboardTests.swift new file mode 100644 index 0000000..4bb8a7a --- /dev/null +++ b/Packages/OpenStatsKit/Tests/OpenStatsUITests/DashboardTests.swift @@ -0,0 +1,114 @@ +import Foundation +import Testing +@testable import Metrics +@testable import OpenStatsUI + +@MainActor +@Suite struct DashboardTests { + private func defaults() -> UserDefaults { + let name = "OpenStatsUITests.Dashboard.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults + } + + // MARK: 设置 + + @Test func dashboardDefaultsAreOffOn8790() { + let settings = AppSettings(defaults: defaults()) + #expect(settings.dashboardEnabled == false) + #expect(settings.dashboardPort == 8790) + } + + @Test func invalidPortFallsBackTo8790() { + let stored = defaults() + stored.set(22, forKey: "dashboardPort") + let settings = AppSettings(defaults: stored) + #expect(settings.dashboardPort == 8790) + } + + @Test func settingsDocumentCarriesDashboardFields() { + let settings = AppSettings(defaults: defaults()) + settings.dashboardEnabled = true + settings.dashboardPort = 8888 + let doc = settings.exportDocument() + #expect(doc.dashboardEnabled == true) + #expect(doc.dashboardPort == 8888) + + let other = AppSettings(defaults: defaults()) + other.apply(doc) + #expect(other.dashboardEnabled == true && other.dashboardPort == 8888) + // 越界端口在套用时被忽略 + var invalid = SettingsDocument() + invalid.dashboardPort = 22 + other.apply(invalid) + #expect(other.dashboardPort == 8888) + } + + // MARK: HTTP 响应路由 + + @Test func serverRoutesByPath() { + let html = Data("".utf8) + let metrics = Data("{}".utf8) + #expect(DashboardServer.makeResponse(path: "/", html: html, metrics: metrics).body == html) + #expect(DashboardServer.makeResponse(path: "/index.html", html: html, metrics: metrics).body == html) + #expect(DashboardServer.makeResponse(path: "/metrics?x=1", html: html, metrics: metrics).body == metrics) + #expect(String(decoding: DashboardServer.makeResponse(path: "/healthz", html: html, metrics: metrics).body, as: UTF8.self) == "ok") + #expect(DashboardServer.makeResponse(path: "/nope", html: html, metrics: metrics).status == 404) + // 响应头里带关闭连接与禁用缓存 + let head = String(decoding: DashboardServer.makeResponse(path: "/metrics", html: html, metrics: metrics).data.prefix(200), as: UTF8.self) + #expect(head.contains("Connection: close")) + #expect(head.contains("no-store")) + } + + // MARK: /metrics 内容 + + @Test func payloadCarriesTheSnapshot() throws { + let store = MetricsStore() + var snapshot = MetricsSnapshot() + snapshot.cpu = CPULoad(total: 0.5, user: 0.3, system: 0.2, perCore: [0.25, 0.75], loadAverage: [1, 2, 3]) + snapshot.memory = MemoryUsage(total: 16 << 30, app: 6 << 30, wired: 2 << 30, compressed: 2 << 30, + cached: 1 << 30, swapUsed: 1 << 20, swapTotal: 1 << 30, pressure: .normal, + uncompressed: 4 << 30, swapInBytes: 0, swapOutBytes: 0) + snapshot.network = NetworkRate(downloadBytesPerSecond: 100, uploadBytesPerSecond: 50, totalDownloaded: 0, totalUploaded: 0) + snapshot.disk = DiskUsage(volumeName: "Macintosh HD", total: 500_000_000_000, available: 250_000_000_000) + snapshot.processes = [ProcessUsage(pid: 42, name: "demo", executablePath: nil, appBundlePath: nil, cpu: 1.5, memory: 1 << 30)] + snapshot.battery = BatteryStatus(level: 0.55, isCharging: false, isPluggedIn: true, isFullyCharged: false, + minutesRemaining: nil, cycleCount: nil, health: nil, adapterWatts: nil, + designCapacity: nil, fullChargeCapacity: nil, voltage: nil, amperage: nil) + store.apply(snapshot) + + let settings = AppSettings(defaults: defaults()) + let controller = DashboardController(settings: settings, store: store) + // 走真实采样路径:两拍后历史里应有两条,累计下载每拍 +200B(100B/s × 2s) + controller.sampleOnce() + controller.sampleOnce() + let data = controller.payloadBox.value + let payload = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + + let system = try #require(payload["system"] as? [String: Any]) + #expect(system["cpu"] as? Double == 50) + #expect(system["cpuPercpu"] as? [Double] == [25, 75]) + #expect(system["load"] as? [Double] == [1, 2, 3]) + #expect((system["memUsedGB"] as? Double ?? 0) > 9.9 && (system["memUsedGB"] as? Double ?? 0) < 10.1) + #expect(system["memTotalGB"] as? Double == 16) + #expect((system["diskPercent"] as? Double ?? 0) > 49 && (system["diskPercent"] as? Double ?? 0) < 51) + #expect(system["batteryPercent"] as? Double == 55) + let processes = try #require(system["processes"] as? [[String: Any]]) + #expect(processes.first?["name"] as? String == "demo") + #expect(processes.first?["cpu"] as? Double == 1.5) + + let history = try #require(payload["history"] as? [String: Any]) + #expect(history["cpu"] as? [Double] == [50, 50]) + #expect(history["netDown"] as? [Double] == [200, 400]) + #expect((history["time"] as? [Double])?.count == 2) + #expect(payload["sampleInterval"] as? Double == 2) + } + + @Test func payloadSurvivesAnEmptyStore() throws { + let controller = DashboardController(settings: AppSettings(defaults: defaults()), store: MetricsStore()) + let data = controller.buildPayload(sample: .init(time: 1, cpu: 0, memory: 0, totalDown: 0, totalUp: 0)) + let payload = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect((payload["system"] as? [String: Any])?["cpu"] as? Double == 0) + } +}