diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift index 6bd7e193a95a..5d1457141369 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift @@ -286,7 +286,15 @@ final class CloudService { guard auth.isSignedIn, unavailableReason == nil else { return } lastRefresh = .now do { - let list = configuration.linkSource == .appServer ? try await appServerMachines() : try await api.listMachines() + let list: [CloudMachine] + if configuration.linkSource == .appServer { + list = try await appServerMachines() + machines.cloudLimits = nil + } else { + let listed = try await api.listMachinesAndLimits() + list = listed.machines + machines.cloudLimits = listed.limits + } lastError = nil reconcile(list) hasLoadedMachines = true @@ -346,6 +354,7 @@ final class CloudService { private func dropAllMachines() { for creation in creations.all { creations.remove(creation) } for session in machines.cloud { machines.remove(session.machineID)?.disconnect() } + machines.cloudLimits = nil hasLoadedMachines = false } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/MachineRegistry.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/MachineRegistry.swift index 315973ad704e..de4a7f96f9cb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/MachineRegistry.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/MachineRegistry.swift @@ -19,6 +19,9 @@ final class MachineRegistry { let local: DaemonService /// Cloud machines in sidebar order (newest last). private(set) var cloud: [CloudMachineSession] = [] + /// The account's Cloud machine cap from the last `/api/vm` list (nil + /// before one, or when the list carried none). Never inferred from `cloud`. + var cloudLimits: CloudMachineLimits? /// SSH machines in the order they were added. private(set) var ssh: [SSHMachineSession] = [] /// Paired servers whose Chief brain session the app shows (`ServerReachService`). diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/CloudCreationRows.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/CloudCreationRows.swift index fcb85d58cec6..8846a7c72405 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/CloudCreationRows.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/CloudCreationRows.swift @@ -1,4 +1,5 @@ import CmuxNextBridge +import CmuxNextCloud import CmuxNextDaemon import CmuxNextSidebar @@ -41,9 +42,10 @@ enum CloudCreationRows { /// (link or daemon gave up, the provider failed it) with its reason as /// the tooltip, so it keeps its header in the one list instead of /// "connecting" for ever. - static func machine(_ session: CloudMachineSession, compatibility: DaemonCompatibility?) -> SidebarMachine { + static func machine(_ session: CloudMachineSession, compatibility: DaemonCompatibility?, limits: CloudMachineLimits? = nil) -> SidebarMachine { var header = SidebarBridge.machine(for: session.daemon, name: session.machine.title, kind: .cloud, live: session.machine.status.isLive, compatibility: compatibility) + header.cost = cost(session.machine, limits: limits) if let failure = session.stage.failure, header.status == .connecting || header.status == .offline { header.status = .failed header.detail = failure @@ -51,6 +53,16 @@ enum CloudCreationRows { return header.redialing(session.link?.redial.isRedialing == true) } + /// A Cloud machine's cost line from server facts: its rate, when it last + /// started (nil unless it is live, so a paused machine shows no running + /// cost) and the account's cap. Nil when the server sent none of them. + static func cost(_ machine: CloudMachine, limits: CloudMachineLimits?) -> SidebarMachine.Cost? { + let since = machine.status.isLive ? machine.runningSince : nil + let cap = limits.map { SidebarMachine.Cost.Cap(maxActiveVms: $0.maxActiveVms, running: $0.activeVmCount) } + guard machine.hourlyRateUsd != nil || since != nil || cap != nil else { return nil } + return SidebarMachine.Cost(hourlyRateUsd: machine.hourlyRateUsd, runningSince: since, cap: cap) + } + /// A Cloud creation's row (cx-lu8f): no workspace yet, so no daemon command and no context menu. static func isRow(_ id: WorkspaceID, _ services: AppServices) -> Bool { services.cloud.creations.creation(row: id.rawValue) != nil diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Machine.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Machine.swift index 1628e6df3df5..999807eadcab 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Machine.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Machine.swift @@ -2,6 +2,7 @@ import AppKit import CmuxNextActions import CmuxNextCloud import CmuxNextDaemon +import CmuxNextSidebar // Per-machine actions: open, terminal, rename, kill, pause/resume, copy, // resize, status, ports, tools, handoff, snapshot, promote to template, @@ -313,7 +314,7 @@ extension CloudHandlers { } /// `describe`'s facts as `action.run` result fields. - static func statusFields(_ session: CloudMachineSession, _ stats: CloudMachineStats) -> [String: ActionResultValue] { + static func statusFields(_ session: CloudMachineSession, _ stats: CloudMachineStats, now: Date = Date()) -> [String: ActionResultValue] { var fields: [String: ActionResultValue] = [ "machine_id": .string(session.machineID), "title": .string(session.machine.title), "state": .string(stats.state), "status": .string(session.effectiveStatus.rawValue), @@ -325,15 +326,29 @@ extension CloudHandlers { if let used = stats.diskUsedMb { fields["disk_used_mb"] = .double(used) } if let total = stats.diskTotalMb { fields["disk_total_mb"] = .double(total) } if let address = session.machine.address?.ipv4 { fields["address"] = .string(address) } + fields.merge(costFields(session.machine, now: now)) { _, new in new } return fields } - static func describe(_ session: CloudMachineSession, _ stats: CloudMachineStats) -> String { + /// The machine's cost as `action.run` fields, from server facts: `hourly_rate_usd`, + /// `running_since` (ISO 8601) and `estimated_cost_usd` (rate x time running). A + /// paused machine has no `running_since` or estimate. + static func costFields(_ machine: CloudMachine, now: Date) -> [String: ActionResultValue] { + guard let cost = CloudCreationRows.cost(machine, limits: nil) else { return [:] } + var fields: [String: ActionResultValue] = [:] + if let rate = cost.hourlyRateUsd { fields["hourly_rate_usd"] = .double(rate) } + if let since = cost.runningSince { fields["running_since"] = .string(since.ISO8601Format()) } + if let spent = cost.estimatedCostUsd(now: now) { fields["estimated_cost_usd"] = .double((spent * 10_000).rounded() / 10_000) } + return fields + } + + static func describe(_ session: CloudMachineSession, _ stats: CloudMachineStats, now: Date = Date()) -> String { var lines = ["\(session.machine.title) (\(session.machineID))", "state: \(stats.state), status: \(session.effectiveStatus.rawValue)"] if let cpus = stats.cpus { lines.append("cpus: \(cpus)" + (stats.cpuPercent.map { String(format: ", %.0f%%", $0) } ?? "")) } if let used = stats.memoryUsedMb, let total = stats.memoryTotalMb { lines.append(String(format: "memory: %.0f / %.0f MB", used, total)) } if let used = stats.diskUsedMb, let total = stats.diskTotalMb { lines.append(String(format: "disk: %.0f / %.0f MB", used, total)) } if let address = session.machine.address?.ipv4 { lines.append("address: \(address)") } + if let usage = CloudCreationRows.cost(session.machine, limits: nil).flatMap({ $0.usage(now: now) }) { lines.append(usage) } return lines.joined(separator: "\n") } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift index 41d291b4e0d6..ef2e035b91b6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift @@ -1,6 +1,7 @@ import AppKit import CmuxNextAgentLanes import CmuxNextBridge +import CmuxNextCloud import CmuxNextCompat import CmuxNextDaemon import CmuxNextDesign @@ -356,7 +357,7 @@ final class SidebarBridge { tabTitle: tabTitle, notificationText: machines.local.store.notificationText(for:), iconAsset: groupIcon, rowSource: rows) for session in machines.cloud { - let header = CloudCreationRows.machine(session, compatibility: machines.compatibility(of: session.daemon)) + let header = CloudCreationRows.machine(session, compatibility: machines.compatibility(of: session.daemon), limits: machines.cloudLimits) sections += SidebarMapping.shared.sections(PersonalSidebar.sections(of: session.daemon, room: profile, machines: machines), machine: header, showsUnread: showsUnread, muted: muted, selectedTab: selectedTab, newTabPages: newTabPages, newTabTitle: Strings.untitledBrowser, pageFace: pageFace, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudAPIClient.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudAPIClient.swift index ca5abaf3b4d6..0bc57382db3c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudAPIClient.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudAPIClient.swift @@ -29,8 +29,14 @@ public struct CloudAPIClient: Sendable { // MARK: Machines public func listMachines() async throws -> [CloudMachine] { - struct List: Decodable { var vms: [CloudMachine] } - return try await send("GET", "/api/vm", as: List.self).vms + try await listMachinesAndLimits().machines + } + + /// `GET /api/vm`: the machines and the account's machine cap (`limits`; + /// nil when the server sent none or sent one this client cannot read). + public func listMachinesAndLimits() async throws -> (machines: [CloudMachine], limits: CloudMachineLimits?) { + let list = try await send("GET", "/api/vm", as: CloudMachineList.self) + return (list.vms, list.limits) } public func machine(_ id: String) async throws -> CloudMachine { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudMachine.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudMachine.swift index 4aeb70b0138a..245876bbd758 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudMachine.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudMachine.swift @@ -136,6 +136,14 @@ public struct CloudMachine: Sendable, Hashable, Identifiable, Decodable { /// when the record carried none (an older server, or the cmux-next /// backend record, whose capability names are not provider operations). public var declaredCapabilities: Capabilities? + /// What one hour of this machine's size costs, in US dollars + /// (`hourlyRateUsd`, from the server's rate table; the app keeps none). + /// Nil when the server has no rate for it (a size not measured yet, an + /// older server). + public var hourlyRateUsd: Double? + /// When the machine last started running (`runningSince`, epoch ms). + /// Nil while it is paused, so a paused machine shows no running cost. + public var runningSince: Date? /// The declared capabilities, or the conservative empty set when the /// record declared none. Gate actions with ``allows(_:)``, not this. @@ -154,6 +162,7 @@ public struct CloudMachine: Sendable, Hashable, Identifiable, Decodable { enum CodingKeys: String, CodingKey { case id, provider, status, displayName, slug, kind, image, imageVersion, createdAt, address, size, capabilities + case hourlyRateUsd, runningSince } public init(id: String, provider: String = "freestyle", status: Status = .running, displayName: String? = nil, @@ -182,6 +191,9 @@ public struct CloudMachine: Sendable, Hashable, Identifiable, Decodable { address = try c.decodeIfPresent(Address.self, forKey: .address) size = try c.decodeIfPresent(Size.self, forKey: .size) declaredCapabilities = try c.decodeIfPresent(Capabilities.self, forKey: .capabilities) + // Display facts: a malformed value drops the cost line, never the machine. + hourlyRateUsd = (try? c.decodeIfPresent(Double.self, forKey: .hourlyRateUsd)) ?? nil + runningSince = ((try? c.decodeIfPresent(Double.self, forKey: .runningSince)) ?? nil).map { Date(timeIntervalSince1970: $0 / 1000) } } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudMachineLimits.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudMachineLimits.swift new file mode 100644 index 000000000000..dc8e65010d1d --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudMachineLimits.swift @@ -0,0 +1,38 @@ +/// The account's machine cap, from `GET /api/vm`'s `limits`. The server +/// resolves it from the plan's entitlements; the app never infers a cap from +/// the machines it lists (#16012). +public struct CloudMachineLimits: Sendable, Hashable, Decodable { + /// Most machines that may run at once. Nil: no cap. Zero: machines are + /// closed on this plan (a zero is never "no cap"). + public var maxActiveVms: Int? + /// Machines running or starting now, as the server counts them. + public var activeVmCount: Int + + public init(maxActiveVms: Int?, activeVmCount: Int) { + self.maxActiveVms = maxActiveVms + self.activeVmCount = activeVmCount + } + + enum CodingKeys: String, CodingKey { case maxActiveVms, activeVmCount } + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + maxActiveVms = try c.decodeIfPresent(Int.self, forKey: .maxActiveVms) + activeVmCount = try c.decodeIfPresent(Int.self, forKey: .activeVmCount) ?? 0 + } +} + +/// `GET /api/vm`'s body. A `limits` this client cannot read drops the cap, +/// never the machine list. +struct CloudMachineList: Decodable { + var vms: [CloudMachine] + var limits: CloudMachineLimits? + + enum CodingKeys: String, CodingKey { case vms, limits } + + init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + vms = try c.decode([CloudMachine].self, forKey: .vms) + limits = (try? c.decodeIfPresent(CloudMachineLimits.self, forKey: .limits)) ?? nil + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarMachineCost+Text.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarMachineCost+Text.swift new file mode 100644 index 000000000000..858dac6ce69a --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarMachineCost+Text.swift @@ -0,0 +1,57 @@ +public import Foundation + +/// A Cloud machine's cost in words, for its sidebar header's tooltip and +/// Cloud Machine Status: "$0.26/h · Running 2h 14m · About $0.58 so far", +/// then the account's cap ("2 of 3 machines running"). Every number comes +/// from the server; this only formats it. +nonisolated extension SidebarMachine.Cost { + /// The usage line, then the cap line, whichever exist. + public func lines(now: Date, locale: Locale = .current) -> [String] { + [usage(now: now, locale: locale), cap?.text].compactMap { $0 } + } + + /// Rate, time running and the estimate so far; while paused only the + /// rate, as "when running", so a paused machine shows no running cost. + /// Nil without a rate or a running time. + public func usage(now: Date, locale: Locale = .current) -> String? { + guard let seconds = runningSeconds(now: now) else { + return hourlyRateUsd.map { Strings.costRateWhenRunning(Self.price($0, locale: locale)) } + } + var parts: [String] = [] + if let rate = hourlyRateUsd { parts.append(Strings.costRate(Self.price(rate, locale: locale))) } + parts.append(Strings.costRunning(Self.duration(seconds, locale: locale))) + if let spent = estimatedCostUsd(now: now) { + parts.append(spent < 0.01 ? Strings.costUnderSoFar(Self.price(0.01, locale: locale)) : Strings.costSoFar(Self.price(spent, locale: locale))) + } + return parts.joined(separator: " · ") + } + + /// US dollars to the cent ("$0.26"). + static func price(_ usd: Double, locale: Locale) -> String { + usd.formatted(.currency(code: "USD").precision(.fractionLength(2)).locale(locale)) + } + + /// Time running in at most two units ("2h 14m", "3d 4h"); under a minute reads as one. + static func duration(_ seconds: TimeInterval, locale: Locale) -> String { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .abbreviated + formatter.allowedUnits = [.day, .hour, .minute] + formatter.maximumUnitCount = 2 + formatter.zeroFormattingBehavior = .dropAll + var calendar = Calendar(identifier: .gregorian) + calendar.locale = locale + formatter.calendar = calendar + return formatter.string(from: max(60, seconds)) ?? "" + } +} + +nonisolated extension SidebarMachine.Cost.Cap { + /// "2 of 3 machines running", "1 running, no machine cap" or "Machines are closed on this plan". + public var text: String { + switch self { + case let .uncapped(running): Strings.capNone(running) + case let .capped(running, max): Strings.capOf(running, max) + case .closed: Strings.capClosed + } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Model/SidebarSection.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Model/SidebarSection.swift index 37420ead8f43..72da05aea90e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Model/SidebarSection.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Model/SidebarSection.swift @@ -1,4 +1,4 @@ -import Foundation +public import Foundation /// A top-level sidebar section: the pinned area or one machine. public nonisolated enum SectionID: Hashable, Sendable { @@ -78,15 +78,65 @@ public nonisolated struct SidebarMachine: Hashable, Sendable { /// The machine's own icon (`session-icon-v1`, from the home session /// registry), drawn in place of its kind's symbol; nil draws the symbol. public var icon: WorkspaceIcon? + /// A Cloud machine's rate, running time and the account's machine cap, + /// shown in the header's tooltip (`Cost.lines`); nil for machines + /// that bill nothing. + public var cost: Cost? public init(id: MachineID, name: String, kind: Kind, status: Status = .connected, detail: String? = nil, - icon: WorkspaceIcon? = nil) { + icon: WorkspaceIcon? = nil, cost: Cost? = nil) { self.id = id self.name = name self.kind = kind self.status = status self.detail = detail self.icon = icon + self.cost = cost + } + + /// What a Cloud machine costs, from server facts only: the rate of its + /// size and when it last started. The estimate is rate x time running. + public nonisolated struct Cost: Hashable, Sendable { + /// The account's machine cap, from the plan (never from the machine list). + public nonisolated enum Cap: Hashable, Sendable { + /// The plan has no cap (the server's `null`). + case uncapped(running: Int) + case capped(running: Int, max: Int) + /// The plan's cap is zero: no machine may run. + case closed + + /// The cap the server's `maxActiveVms` names: nil is no cap, zero is closed. + public init(maxActiveVms: Int?, running: Int) { + switch maxActiveVms { + case nil: self = .uncapped(running: running) + case let max? where max <= 0: self = .closed + case let max?: self = .capped(running: running, max: max) + } + } + } + + /// US dollars per running hour; nil when the server has no rate for the size. + public var hourlyRateUsd: Double? + /// When the machine last started; nil while it is paused. + public var runningSince: Date? + public var cap: Cap? + + public init(hourlyRateUsd: Double?, runningSince: Date?, cap: Cap?) { + self.hourlyRateUsd = hourlyRateUsd + self.runningSince = runningSince + self.cap = cap + } + + /// Seconds running at `now`; nil while paused. + public func runningSeconds(now: Date) -> TimeInterval? { + runningSince.map { max(0, now.timeIntervalSince($0)) } + } + + /// What this run has cost so far at `now`: nil while paused or without a rate. + public func estimatedCostUsd(now: Date) -> Double? { + guard let rate = hourlyRateUsd, let seconds = runningSeconds(now: now) else { return nil } + return rate * seconds / 3600 + } } /// A connected machine whose link redials reads as reconnecting diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Resources/Localizable.xcstrings index 5a8561d0782b..9f95324c1319 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Resources/Localizable.xcstrings @@ -22806,6 +22806,1054 @@ } } }, + "sidebar.machine.cost.rate": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@/ساعة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%@/sat" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%@/t" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@/Std." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@/h" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@/hora" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@/heure" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%@/ora" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@/時間" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "%@/ម៉ោង" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@/시간" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%@/t" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%@/godz." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%@/hora" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@/ч" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "%@/ชม." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@/sa" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%@/год" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "%@/giờ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@/小时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@/小時" + } + } + } + }, + "sidebar.machine.cost.rateWhenRunning": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@/ساعة أثناء التشغيل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%@/h dok radi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%@/t, når den kører" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@/Std. im Betrieb" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@/h when running" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@/h en ejecución" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@/h en fonctionnement" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%@/h in esecuzione" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行中は %@/時間" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "%@/ម៉ោង ពេលកំពុងដំណើរការ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행 중 %@/시간" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%@/t når den kjører" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%@/godz. podczas działania" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%@/h em execução" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@/ч во время работы" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "%@/ชม. ขณะทำงาน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "çalışırken %@/sa" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%@/год під час роботи" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "%@/giờ khi chạy" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行时 %@/小时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "執行時 %@/小時" + } + } + } + }, + "sidebar.machine.cost.running": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعمل منذ %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Radi %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kørt i %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Läuft seit %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Running %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "En ejecución desde hace %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En marche depuis %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "In esecuzione da %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行中 %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានដំណើរការ %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 동안 실행 중" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kjørt i %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Działa od %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Em execução há %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Работает %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทำงานมา %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ süredir çalışıyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Працює %@" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đã chạy %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已运行 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已執行 %@" + } + } + } + }, + "sidebar.machine.cost.soFar": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حوالي %@ حتى الآن" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Do sada oko %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Omkring %@ indtil nu" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bisher etwa %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "About %@ so far" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Unos %@ hasta ahora" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Environ %@ jusqu’ici" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Circa %@ finora" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "これまでに約 %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ប្រហែល %@ រហូតមកដល់ពេលនេះ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지금까지 약 %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Omtrent %@ så langt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dotąd około %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cerca de %@ até agora" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пока около %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ประมาณ %@ จนถึงตอนนี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Şu ana kadar yaklaşık %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Поки близько %@" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Khoảng %@ đến nay" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "目前约 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "目前約 %@" + } + } + } + }, + "sidebar.machine.cost.underSoFar": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أقل من %@ حتى الآن" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Do sada ispod %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Under %@ indtil nu" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bisher unter %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Under %@ so far" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Menos de %@ hasta ahora" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Moins de %@ jusqu’ici" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Meno di %@ finora" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "これまでに %@ 未満" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "តិចជាង %@ រហូតមកដល់ពេលនេះ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지금까지 %@ 미만" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Under %@ så langt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dotąd poniżej %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Menos de %@ até agora" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пока меньше %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่ถึง %@ จนถึงตอนนี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Şu ana kadar %@ altında" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Поки менше %@" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Dưới %@ đến nay" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "目前不到 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "目前不到 %@" + } + } + } + }, + "sidebar.machine.cap.of": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%1$lld من %2$lld أجهزة قيد التشغيل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Radi mašina: %1$lld od %2$lld" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%1$lld af %2$lld maskiner kører" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$lld von %2$lld Maschinen laufen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld of %lld machines running" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$lld de %2$lld máquinas en ejecución" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$lld machines sur %2$lld en marche" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%1$lld di %2$lld macchine in esecuzione" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%2$lld 台中 %1$lld 台が実行中" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ម៉ាស៊ីន %1$lld ក្នុងចំណោម %2$lld កំពុងដំណើរការ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%2$lld대 중 %1$lld대 실행 중" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%1$lld av %2$lld maskiner kjører" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Działające maszyny: %1$lld z %2$lld" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%1$lld de %2$lld máquinas em execução" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Работает машин: %1$lld из %2$lld" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เครื่องทำงาน %1$lld จาก %2$lld" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%2$lld makineden %1$lld tanesi çalışıyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Працює машин: %1$lld з %2$lld" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "%1$lld/%2$lld máy đang chạy" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%2$lld 台中有 %1$lld 台在运行" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%2$lld 台中有 %1$lld 台在執行" + } + } + } + }, + "sidebar.machine.cap.none": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%lld قيد التشغيل، بلا حد للأجهزة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Radi: %lld, bez ograničenja mašina" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%lld kører, ingen maskingrænse" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld laufen, kein Maschinenlimit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld running, no machine cap" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%lld en ejecución, sin límite de máquinas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%lld en marche, sans limite de machines" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%lld in esecuzione, nessun limite di macchine" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld 台が実行中、台数制限なし" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងដំណើរការ %lld គ្មានដែនកំណត់ម៉ាស៊ីន" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld대 실행 중, 머신 제한 없음" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%lld kjører, ingen maskingrense" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Działa: %lld, bez limitu maszyn" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%lld em execução, sem limite de máquinas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Работает: %lld, без лимита машин" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทำงาน %lld เครื่อง ไม่จำกัดจำนวนเครื่อง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%lld çalışıyor, makine sınırı yok" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Працює: %lld, без ліміту машин" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "%lld đang chạy, không giới hạn máy" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 台在运行,无数量上限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lld 台在執行,無數量上限" + } + } + } + }, + "sidebar.machine.cap.closed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الأجهزة غير متاحة في هذه الخطة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Mašine nisu dostupne u ovom planu" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Maskiner er lukket på denne plan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Maschinen sind in diesem Tarif nicht verfügbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Machines are closed on this plan" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las máquinas no están disponibles en este plan" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les machines ne sont pas disponibles avec cette offre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Le macchine non sono disponibili con questo piano" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このプランではマシンを使用できません" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "គម្រោងនេះមិនបើកម៉ាស៊ីនទេ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 요금제에서는 머신을 사용할 수 없습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Maskiner er stengt på denne planen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Maszyny są niedostępne w tym planie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Máquinas não estão disponíveis neste plano" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "На этом тарифе машины недоступны" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แผนนี้ไม่เปิดให้ใช้เครื่อง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu planda makineler kapalı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "На цьому тарифі машини недоступні" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Gói này không cho dùng máy" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此方案不提供机器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此方案不提供機器" + } + } + } + }, "sidebar.doneFold.title": { "extractionState": "manual", "localizations": { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Strings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Strings.swift index cdc8fddcbbe8..39f4b3f728ac 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Strings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Strings.swift @@ -27,6 +27,22 @@ enum Strings { static var statusConnecting: String { String(localized: "sidebar.machine.connecting", defaultValue: "Connecting…", bundle: .module) } static var statusReconnecting: String { String(localized: "sidebar.machine.reconnecting", defaultValue: "Reconnecting…", bundle: .module) } static var statusOffline: String { String(localized: "sidebar.machine.offline", defaultValue: "Offline", bundle: .module) } + nonisolated static func costRate(_ price: String) -> String { String(localized: "sidebar.machine.cost.rate", defaultValue: "\(price)/h", bundle: .module) } + nonisolated static func costRateWhenRunning(_ price: String) -> String { + String(localized: "sidebar.machine.cost.rateWhenRunning", defaultValue: "\(price)/h when running", bundle: .module) + } + nonisolated static func costRunning(_ duration: String) -> String { String(localized: "sidebar.machine.cost.running", defaultValue: "Running \(duration)", bundle: .module) } + nonisolated static func costSoFar(_ price: String) -> String { String(localized: "sidebar.machine.cost.soFar", defaultValue: "About \(price) so far", bundle: .module) } + nonisolated static func costUnderSoFar(_ price: String) -> String { + String(localized: "sidebar.machine.cost.underSoFar", defaultValue: "Under \(price) so far", bundle: .module) + } + nonisolated static func capOf(_ running: Int, _ max: Int) -> String { + String(localized: "sidebar.machine.cap.of", defaultValue: "\(running) of \(max) machines running", bundle: .module) + } + nonisolated static func capNone(_ running: Int) -> String { + String(localized: "sidebar.machine.cap.none", defaultValue: "\(running) running, no machine cap", bundle: .module) + } + nonisolated static var capClosed: String { String(localized: "sidebar.machine.cap.closed", defaultValue: "Machines are closed on this plan", bundle: .module) } static var statusUpdateAvailable: String { String(localized: "sidebar.machine.updateAvailable", defaultValue: "Update available", bundle: .module) } static var statusUpdateRequired: String { String(localized: "sidebar.machine.updateRequired", defaultValue: "Update needed", bundle: .module) } static var statusInstallRequired: String { String(localized: "sidebar.machine.installRequired", defaultValue: "Install needed", bundle: .module) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SectionHeaderRowView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SectionHeaderRowView.swift index 6abb466350ac..56976560fbb2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SectionHeaderRowView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SectionHeaderRowView.swift @@ -21,6 +21,10 @@ final class SectionHeaderRowView: SidebarRowView { private var collapsed = false /// No workspaces: nothing to fold, so hover shows no chevron (cx-odqn). private var isEmpty = false + /// The status part of the tooltip, and a Cloud machine's cost, kept so a + /// hover rewrites the running time and estimate as of that moment. + private var statusTip: String? + private var cost: SidebarMachine.Cost? var onAdd: (() -> Void)? required init(key: SidebarRowKey) { @@ -89,12 +93,21 @@ final class SectionHeaderRowView: SidebarRowView { } } + /// The header's tooltip: the status words or detail, then a Cloud + /// machine's cost and the account's cap (`SidebarMachine.Cost.lines`) as of `now`. + static func machineToolTip(status: String?, cost: SidebarMachine.Cost?, now: Date) -> String? { + let lines = [status].compactMap { $0?.isEmpty == false ? $0 : nil } + (cost?.lines(now: now) ?? []) + return lines.isEmpty ? nil : lines.joined(separator: "\n") + } + func configure(_ section: SidebarSection, row: SidebarRow) { let content = Content( kind: section.kind, isHostGroup: section.isHostGroup, titlesProjects: row.titlesProjects, collapsed: row.isCollapsed, isEmpty: section.nodes.isEmpty, fontSize: SidebarStyle.headerFont.pointSize, iconSize: Metrics.smallIconSize ) guard needsConfigure(content) else { return } + statusTip = nil + cost = nil collapsed = row.isCollapsed isEmpty = content.isEmpty let symbol: IconName @@ -131,9 +144,11 @@ final class SectionHeaderRowView: SidebarRowView { } let words = Self.statusWords(machine.status) badgeText = Self.statusText(machine.status, hostGroup: section.isHostGroup) - themedToolTip = Self.statusToolTip(words: words, detail: machine.detail, bareDot: statusTone != nil && badgeText == nil) + statusTip = Self.statusToolTip(words: words, detail: machine.detail, bareDot: statusTone != nil && badgeText == nil) + cost = machine.kind == .cloud ? machine.cost : nil + themedToolTip = Self.machineToolTip(status: statusTip, cost: cost, now: Date()) setAccessibilityLabel(machine.name + ", " + words) - setAccessibilityHelp(machine.detail) + setAccessibilityHelp(cost == nil ? machine.detail : themedToolTip) } // The only machine needs no name or status: the header heads the // workspace list, apart from the destinations above it. @@ -142,6 +157,8 @@ final class SectionHeaderRowView: SidebarRowView { ownIcon = nil statusTone = nil badgeText = nil + statusTip = nil + cost = nil themedToolTip = nil setAccessibilityHelp(nil) } @@ -247,6 +264,8 @@ final class SectionHeaderRowView: SidebarRowView { override func hoverChanged() { super.hoverChanged() + // The running time and estimate move with the clock: a hover reads them now. + if isHovered, cost != nil { themedToolTip = Self.machineToolTip(status: statusTip, cost: cost, now: Date()) } needsLayout = true } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudMachineCostTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudMachineCostTests.swift new file mode 100644 index 000000000000..f14d9e2d6c5d --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudMachineCostTests.swift @@ -0,0 +1,177 @@ +import AppKit +import CmuxNextActions +@testable import CmuxNextApp +@testable import CmuxNextCloud +import CmuxNextDaemon +@testable import CmuxNextSidebar +import Foundation +import Testing + +/// The sidebar's strings; the app module has its own `Strings`. +private typealias SidebarStrings = CmuxNextSidebar.Strings + +/// A Cloud machine's cost line (pilot blocker 1b): the rate of its size, +/// how long it has run and the estimate so far, from server facts only, and +/// the account's cap from `limits` (null is no cap, zero is closed; never +/// inferred from the machine list, #16012). +@MainActor @Suite(.serialized) struct CloudMachineCostTests { + private static let enUS = Locale(identifier: "en_US") + private static let now = Date(timeIntervalSince1970: 1_800_000_000) + + private static func machine(_ status: CloudMachine.Status, rate: Double? = 0.2645, runningFor seconds: TimeInterval? = 8_040) -> CloudMachine { + var machine = CloudMachine(id: "vm-cost", status: status, displayName: "api") + machine.hourlyRateUsd = rate + machine.runningSince = seconds.map { now.addingTimeInterval(-$0) } + return machine + } + + // MARK: Rate and estimate formatting + + @Test func rateAndDurationFormatToTheCentAndTwoUnits() { + #expect(SidebarMachine.Cost.price(0.2645, locale: Self.enUS) == "$0.26") + #expect(SidebarMachine.Cost.price(12.3456, locale: Self.enUS) == "$12.35") + #expect(SidebarMachine.Cost.duration(8_040, locale: Self.enUS) == "2h 14m") + #expect(SidebarMachine.Cost.duration(3 * 86_400 + 4 * 3_600 + 59, locale: Self.enUS) == "3d 4h") + // Under a minute reads as one minute, never "0m". + #expect(SidebarMachine.Cost.duration(12, locale: Self.enUS) == "1m") + } + + @Test func aRunningMachineShowsRateTimeAndEstimate() throws { + let cost = try #require(CloudCreationRows.cost(Self.machine(.running), limits: nil)) + #expect(cost.runningSeconds(now: Self.now) == 8_040) + // 0.2645 $/h x 2h 14m = $0.5907. + #expect(abs((cost.estimatedCostUsd(now: Self.now) ?? 0) - 0.590_716) < 0.000_01) + #expect(cost.usage(now: Self.now, locale: Self.enUS) + == [SidebarStrings.costRate("$0.26"), SidebarStrings.costRunning("2h 14m"), SidebarStrings.costSoFar("$0.59")].joined(separator: " · ")) + } + + @Test func aFreshMachineReadsUnderOneCent() throws { + let cost = try #require(CloudCreationRows.cost(Self.machine(.running, runningFor: 60), limits: nil)) + #expect(cost.usage(now: Self.now, locale: Self.enUS) + == [SidebarStrings.costRate("$0.26"), SidebarStrings.costRunning("1m"), SidebarStrings.costUnderSoFar("$0.01")].joined(separator: " · ")) + } + + @Test func aMachineWithoutARateShowsOnlyItsTime() throws { + let cost = try #require(CloudCreationRows.cost(Self.machine(.running, rate: nil), limits: nil)) + #expect(cost.estimatedCostUsd(now: Self.now) == nil) + #expect(cost.usage(now: Self.now, locale: Self.enUS) == SidebarStrings.costRunning("2h 14m")) + } + + // MARK: Paused + + /// A paused machine costs no compute: no running time, no estimate, only + /// its rate "when running", even if a stale start came with the record. + @Test func aPausedMachineShowsNoRunningCost() throws { + let cost = try #require(CloudCreationRows.cost(Self.machine(.paused), limits: nil)) + #expect(cost.runningSince == nil) + #expect(cost.estimatedCostUsd(now: Self.now) == nil) + #expect(cost.usage(now: Self.now, locale: Self.enUS) == SidebarStrings.costRateWhenRunning("$0.26")) + + let fields = CloudHandlers.costFields(Self.machine(.paused), now: Self.now) + #expect(fields["hourly_rate_usd"] == .double(0.2645)) + #expect(fields["running_since"] == nil) + #expect(fields["estimated_cost_usd"] == nil) + } + + @Test func aMachineWithNoCostFactsHasNoCostLine() { + #expect(CloudCreationRows.cost(Self.machine(.paused, rate: nil, runningFor: nil), limits: nil) == nil) + } + + // MARK: Cap + + @Test func theCapFollowsTheServerLimitNullNOrZero() { + #expect(SidebarMachine.Cost.Cap(maxActiveVms: nil, running: 2) == .uncapped(running: 2)) + #expect(SidebarMachine.Cost.Cap(maxActiveVms: 3, running: 2) == .capped(running: 2, max: 3)) + #expect(SidebarMachine.Cost.Cap(maxActiveVms: 0, running: 0) == .closed) + + #expect(SidebarMachine.Cost.Cap.uncapped(running: 2).text == SidebarStrings.capNone(2)) + #expect(SidebarMachine.Cost.Cap.capped(running: 2, max: 3).text == SidebarStrings.capOf(2, 3)) + #expect(SidebarMachine.Cost.Cap.closed.text == SidebarStrings.capClosed) + } + + /// The cap comes from `limits`, not from how many machines the list holds. + @Test func theCapComesFromLimitsNotTheList() throws { + let capped = try #require(CloudCreationRows.cost(Self.machine(.running), limits: CloudMachineLimits(maxActiveVms: 3, activeVmCount: 2))) + #expect(capped.cap == .capped(running: 2, max: 3)) + let closed = try #require(CloudCreationRows.cost(Self.machine(.paused, rate: nil, runningFor: nil), + limits: CloudMachineLimits(maxActiveVms: 0, activeVmCount: 0))) + #expect(closed.cap == .closed) + #expect(closed.lines(now: Self.now, locale: Self.enUS) == [SidebarStrings.capClosed]) + #expect(try #require(CloudCreationRows.cost(Self.machine(.running), limits: nil)).cap == nil) + } + + // MARK: Decoding + + @Test func theMachineListDecodesRateStartAndLimits() throws { + let body = #""" + {"vms":[{"id":"vm-1","status":"running","hourlyRateUsd":0.2645,"runningSince":1800000000000}, + {"id":"vm-2","status":"paused","hourlyRateUsd":null,"runningSince":null}], + "limits":{"maxActiveVms":null,"activeVmCount":1}} + """# + let list = try JSONDecoder().decode(CloudMachineList.self, from: Data(body.utf8)) + #expect(list.vms[0].hourlyRateUsd == 0.2645) + #expect(list.vms[0].runningSince == Self.now) + #expect(list.vms[1].hourlyRateUsd == nil) + #expect(list.vms[1].runningSince == nil) + #expect(list.limits == CloudMachineLimits(maxActiveVms: nil, activeVmCount: 1)) + + let capped = try JSONDecoder().decode(CloudMachineList.self, from: Data(#"{"vms":[],"limits":{"maxActiveVms":3,"activeVmCount":2}}"#.utf8)) + #expect(capped.limits == CloudMachineLimits(maxActiveVms: 3, activeVmCount: 2)) + let closed = try JSONDecoder().decode(CloudMachineList.self, from: Data(#"{"vms":[],"limits":{"maxActiveVms":0,"activeVmCount":0}}"#.utf8)) + #expect(closed.limits?.maxActiveVms == 0) + } + + /// A cost or cap this client cannot read drops the line, never the machines. + @Test func malformedCostFactsKeepTheMachines() throws { + let body = #"{"vms":[{"id":"vm-1","status":"running","hourlyRateUsd":"cheap","runningSince":"soon"}],"limits":"open"}"# + let list = try JSONDecoder().decode(CloudMachineList.self, from: Data(body.utf8)) + #expect(list.vms.map(\.id) == ["vm-1"]) + #expect(list.vms[0].hourlyRateUsd == nil) + #expect(list.vms[0].runningSince == nil) + #expect(list.limits == nil) + #expect(try JSONDecoder().decode(CloudMachineList.self, from: Data(#"{"vms":[]}"#.utf8)).limits == nil) + } + + // MARK: Surfaces + + /// Hovering a Cloud machine's sidebar header shows its cost under the status. + @Test func theCloudHeaderTooltipCarriesTheCostLine() throws { + let cost = SidebarMachine.Cost(hourlyRateUsd: 0.2645, runningSince: Date().addingTimeInterval(-8_040), cap: .capped(running: 2, max: 3)) + let tip = try #require(Self.header(SidebarMachine(id: MachineID("vm-cost"), name: "api", kind: .cloud, status: .connected, cost: cost)).themedToolTip) + let lines = tip.components(separatedBy: "\n") + #expect(lines.first == SidebarStrings.statusConnected) + #expect(lines.last == SidebarStrings.capOf(2, 3)) + #expect(lines.count == 3) + + #expect(SectionHeaderRowView.machineToolTip(status: SidebarStrings.statusConnected, cost: cost, now: Self.now) + == ([SidebarStrings.statusConnected] + cost.lines(now: Self.now)).joined(separator: "\n")) + #expect(SectionHeaderRowView.machineToolTip(status: nil, cost: nil, now: Self.now) == nil) + } + + /// Only Cloud machines bill: another kind's header ignores a cost. + @Test func anSSHHeaderShowsNoCost() { + let cost = SidebarMachine.Cost(hourlyRateUsd: 0.2645, runningSince: Self.now, cap: .closed) + #expect(Self.header(SidebarMachine(id: MachineID("box"), name: "box", kind: .ssh, status: .connected, cost: cost)).themedToolTip + == SidebarStrings.statusConnected) + } + + /// `cloud machine-status` answers agents with the cost fields. + @Test func machineStatusFieldsCarryTheCost() { + let fields = CloudHandlers.costFields(Self.machine(.running), now: Self.now) + #expect(fields["hourly_rate_usd"] == .double(0.2645)) + #expect(fields["running_since"] == .string(Self.now.addingTimeInterval(-8_040).ISO8601Format())) + #expect(fields["estimated_cost_usd"] == .double(0.5907)) + } + + private static func header(_ machine: SidebarMachine) -> SectionHeaderRowView { + let section = CmuxNextSidebar.SidebarSection(kind: .machine(machine), nodes: [ + .workspace(SidebarWorkspace(id: WorkspaceID("ws-\(machine.id.rawValue)"), title: "api", lastActivity: Date())), + ]) + let row = SidebarRow(key: .section(section.id), y: 0, height: 28, section: section.id, workspace: nil, siblingIndex: 0, + isLastInGroup: true, isCollapsed: false, childCount: 1) + let view = SectionHeaderRowView(key: .section(section.id)) + view.frame = NSRect(x: 0, y: 0, width: 260, height: 28) + view.configure(section, row: row) + return view + } +} diff --git a/skills/cmux-cloud-vm/references/commands.md b/skills/cmux-cloud-vm/references/commands.md index 8e7fbfa62dd5..c5807f6171b4 100644 --- a/skills/cmux-cloud-vm/references/commands.md +++ b/skills/cmux-cloud-vm/references/commands.md @@ -35,7 +35,7 @@ cmux cloud open-machine --target machine:vm-… cmux cloud new-terminal-on-machine --target machine:vm-… cmux cloud rename-machine --target machine:vm-… --name