Skip to content
Open
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
4,148 changes: 3,922 additions & 226 deletions Plugins/ContainerIndicator/Resources/Localizable.xcstrings

Large diffs are not rendered by default.

127 changes: 122 additions & 5 deletions Plugins/ContainerIndicator/Sources/ContainerIndicatorController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public final class ContainerIndicatorController: ObservableObject {
private let commandRunner: any ContainerCommandRunning
private let commandPathProvider: () -> String?
private let localization: PluginLocalization
private let terminalLauncher: any ContainerTerminalLaunching
private let now: () -> Date
private var pollTask: Task<Void, Never>?
/// The single refresh currently allowed to publish. Superseding it or
/// hiding the panel cancels it, which also terminates the CLI command it
Expand All @@ -34,17 +36,26 @@ public final class ContainerIndicatorController: ObservableObject {
/// Identifies the newest refresh; older ones compare against it and drop
/// the results they gathered before being replaced.
private var refreshGeneration: UInt64 = 0
/// Previous cumulative counters and the moment they were read. Live rates
/// only exist as a delta, and the baseline is dropped whenever monitoring
/// pauses so a long gap never turns into an averaged "current" value.
private var previousStats: [String: ContainerStatsCounters] = [:]
private var previousStatsAt: Date?
public private(set) var isMonitoring = false

public init(
commandRunner: any ContainerCommandRunning = ContainerCommandRunner(),
localization: PluginLocalization = PluginLocalization(bundle: .main),
commandPathProvider: @escaping () -> String? = { ContainerExecutableLocator.locate() }
commandPathProvider: @escaping () -> String? = { ContainerExecutableLocator.locate() },
terminalLauncher: any ContainerTerminalLaunching = ContainerTerminalLauncher(),
now: @escaping () -> Date = Date.init
) {
self.commandRunner = commandRunner
self.localization = localization
self.commandPath = commandPathProvider()
self.commandPathProvider = commandPathProvider
self.terminalLauncher = terminalLauncher
self.now = now
}

public var isCommandAvailable: Bool {
Expand Down Expand Up @@ -101,6 +112,10 @@ public final class ContainerIndicatorController: ObservableObject {
isMonitoring = false
pollTask?.cancel()
pollTask = nil
// A paused monitor has no usable baseline: rates computed across the
// gap would describe an average instead of the current load.
previousStats = [:]
previousStatsAt = nil
// Cancelling the refresh also cancels the CLI command it is waiting on,
// so a hidden panel never leaves a `container` process running behind it.
cancelInFlightRefresh()
Expand Down Expand Up @@ -184,6 +199,8 @@ public final class ContainerIndicatorController: ObservableObject {

guard status == .running else {
isChecking = false
previousStats = [:]
previousStatsAt = nil
if status == .unknown {
checkErrorMessage = failureDetail(
exitCode: statusResult.exitCode,
Expand All @@ -200,7 +217,7 @@ public final class ContainerIndicatorController: ObservableObject {
status: status,
containers: keepsEntries ? previous.containers : [],
machines: keepsEntries ? previous.machines : [],
updatedAt: Date()
updatedAt: now()
),
generation: generation
)
Expand Down Expand Up @@ -293,16 +310,83 @@ public final class ContainerIndicatorController: ObservableObject {
}
}

// Live counters come from one stats read for every running container.
// The command samples twice internally, so a refresh takes about two
// seconds longer while anything runs; with nothing running it is
// skipped entirely.
var statsFailure: String?
if containers.contains(where: \.isRunning) {
let statsResult = try? await commandRunner.run(
executable: path,
arguments: ["stats", "--format", "json", "--no-stream"]
)
guard isCurrent(generation: generation) else { return }

if statsResult?.exitCode == 0 {
let counters = ContainerIndicatorParser.stats(
fromJSON: statsResult?.standardOutput ?? ""
)
let sampledAt = now()
let elapsed = previousStatsAt.map { sampledAt.timeIntervalSince($0) } ?? 0
containers = containers.map { container in
guard container.isRunning else { return container }
guard let current = counters[container.id] else {
return container.withPerformance(nil)
}
return container.withPerformance(
ContainerIndicatorStatsMath.performance(
previous: previousStats[container.id],
current: current,
elapsed: elapsed,
// 100% means every core the container was given is
// busy, matching the "CPU N 核" line above it.
cores: max(1, container.cpus),
sampledAt: sampledAt
)
)
}
previousStats = counters
previousStatsAt = sampledAt
} else {
statsFailure = listFailureMessage(
label: localization.string(
"panel.error.statsReadFailed",
defaultValue: "性能数据读取失败"
),
exitCode: statsResult?.exitCode,
standardError: statsResult?.standardError ?? ""
)
// A failed read keeps the numbers already on screen; the card
// marks them as stale with the warning line.
let previousPerformance = Dictionary(
uniqueKeysWithValues: previous.containers.compactMap { container in
container.performance.map { (container.id, $0) }
}
)
containers = containers.map { container in
guard container.performance == nil,
let performance = previousPerformance[container.id] else {
return container
}
return container.withPerformance(performance)
}
}
} else {
previousStats = [:]
previousStatsAt = nil
}

isChecking = false
checkErrorMessage = nil
publish(
ContainerSnapshot(
status: .running,
containers: containers,
machines: machines,
updatedAt: Date(),
updatedAt: now(),
containersFailure: containersFailure,
machinesFailure: machinesFailure
machinesFailure: machinesFailure,
statsFailure: statsFailure
),
generation: generation
)
Expand Down Expand Up @@ -343,6 +427,39 @@ public final class ContainerIndicatorController: ObservableObject {
])
}

// MARK: - Terminal

/// Opens an interactive shell in the user's default terminal. The CLI must
/// run in a real TTY, so the launcher hands a `.command` script to
/// LaunchServices instead of spawning the process here.
public func openTerminal(target: ContainerTerminalTarget) {
resolveCommandPathIfNeeded()
guard let path = commandPath else {
operationErrorMessage = localization.string(
"panel.error.terminalUnavailable",
defaultValue: "container 命令不可用"
)
onStateChange?()
return
}

operationErrorMessage = nil
do {
try terminalLauncher.launch(target: target, executable: path)
} catch ContainerTerminalLaunchError.invalidIdentifier {
operationErrorMessage = localization.string(
"panel.error.terminalInvalidIdentifier",
defaultValue: "容器标识无效"
)
} catch {
operationErrorMessage = localization.string(
"panel.error.terminalOpenFailed",
defaultValue: "无法打开终端"
)
}
onStateChange?()
}

/// Runs a command sequence as one operation: it stops at the first failure
/// and publishes exactly one outcome, then refreshes the snapshot.
private func perform(sequences: [[String]]) {
Expand Down Expand Up @@ -409,7 +526,7 @@ public final class ContainerIndicatorController: ObservableObject {
status: status,
containers: snapshot.containers,
machines: snapshot.machines,
updatedAt: Date()
updatedAt: now()
)
}

Expand Down
Loading
Loading