Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import CmuxNextBridge
import CmuxNextCloud
import CmuxNextDaemon
import CmuxNextSidebar

Expand Down Expand Up @@ -41,16 +42,27 @@ 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
}
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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")
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AppKit
import CmuxNextAgentLanes
import CmuxNextBridge
import CmuxNextCloud
import CmuxNextCompat
import CmuxNextDaemon
import CmuxNextDesign
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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) }
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading