Files
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

791 lines
35 KiB
Swift

// PR 7 — Status screen. Lays out:
// • ServerHeroCard (the same card the Server screen mounts; defined in
// ServerScreen.swift)
// • Serving Stats — prefill/cache tiles + average speed from /admin/api/stats
// • System — slice of /admin/api/global-settings + uptime from /api/stats
// • Updates — release check status + auto-check/auto-notify prefs
// • Active Now — active_models slice from /api/stats
//
// Polling is on-screen-only: a 5s timer ticks while the view is visible.
import SwiftUI
struct StatusScreen: View {
@Environment(AppServices.self) private var services
@State private var vm = StatusScreenVM()
@State private var showingClearStatsConfirm = false
@State private var showingClearSsdCacheConfirm = false
@State private var showingClearHotCacheConfirm = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Same hero card the Server screen uses (omlx-screens.jsx:75
// and :833 reuse `ServerHero`). Pass no VM — Status doesn't
// expose port/host editors, so Restart just bounces the cached
// endpoint.
ServerHeroCard()
SectionHeader(String(localized: "status.session_stats",
defaultValue: "Serving Stats",
comment: "Section header for the serving stats tiles")) {
HStack(spacing: 10) {
Segmented(selection: $vm.scope, options: [
("session", String(localized: "status.scope.session",
defaultValue: "Session",
comment: "Segmented control option: session-scope stats")),
("alltime", String(localized: "status.scope.alltime",
defaultValue: "All Time",
comment: "Segmented control option: all-time stats")),
])
Button {
showingClearStatsConfirm = true
} label: {
Image(systemName: "trash")
.font(.system(size: 11, weight: .semibold))
}
.buttonStyle(.omlx(.plain, size: .small))
.help(vm.scope == "alltime"
? String(localized: "status.clear.alltime.help",
defaultValue: "Clear all-time stats",
comment: "Tooltip on the clear button when all-time scope is selected")
: String(localized: "status.clear.session.help",
defaultValue: "Clear session stats",
comment: "Tooltip on the clear button when session scope is selected"))
.disabled(vm.stats == nil)
}
}
StatTilesRow(stats: vm.stats)
SectionHeader(String(localized: "status.speed.section_label",
defaultValue: "Average Speed",
comment: "Header label above average serving speed metrics"))
AverageSpeedTilesRow(stats: vm.stats)
SectionHeader(String(localized: "status.section.active_now",
defaultValue: "Active Now",
comment: "Section header for the currently active models list"))
ActiveNowList(models: vm.stats?.activeModels.models ?? [])
SectionHeader(String(localized: "status.section.system",
defaultValue: "System",
comment: "Section header for system status rows"),
subtitle: vm.systemSubtitle)
ListGroup {
Row(label: String(localized: "status.row.gpu_memory",
defaultValue: "GPU Wired Memory",
comment: "Row label for GPU wired memory")) {
GpuMemoryTrailing(stats: vm.stats)
}
Row(label: String(localized: "status.row.system_ram",
defaultValue: "System RAM",
comment: "Row label for system RAM")) {
SystemRamTrailing(metrics: vm.metrics)
}
Row(
label: String(localized: "status.row.gpu_utilization",
defaultValue: "GPU Utilization",
comment: "Row label for GPU utilization"),
sublabel: String(localized: "status.row.gpu_utilization.sub",
defaultValue: "Approximate · active request load",
comment: "Sublabel for GPU utilization row")
) {
Text(vm.gpuUtilizationText)
.font(.omlxMono(12))
}
Row(label: String(localized: "status.row.thermal_state",
defaultValue: "Thermal State",
comment: "Row label for thermal state")) {
ThermalTrailing(state: vm.metrics.thermalState)
}
Row(label: String(localized: "status.row.server_uptime",
defaultValue: "Server Uptime",
comment: "Row label for server uptime")) {
Text(vm.uptimeText)
.font(.omlxMono(12))
}
Row(label: String(localized: "status.row.version",
defaultValue: "oMLX Version",
comment: "Row label for the running oMLX version"),
isLast: true) {
Text(vm.versionText)
.font(.omlxMono(12))
.foregroundStyle(.secondary)
}
}
RuntimeCacheSection(
cache: vm.stats?.runtimeCache,
onClearSsdTap: { showingClearSsdCacheConfirm = true },
onClearHotTap: { showingClearHotCacheConfirm = true }
)
SectionHeader(String(localized: "status.updates.title",
defaultValue: "Updates",
comment: "Section header for the updates section"))
UpdatesSection(updates: services.updates)
if let error = vm.lastError {
Text(error)
.font(.omlxText(11))
.foregroundStyle(.red)
.padding(.horizontal, 18).padding(.top, 8)
}
}
.task(id: vm.scope) {
await vm.start(client: services.client)
}
.onDisappear { vm.stop() }
.confirmationDialog(
vm.scope == "alltime"
? String(localized: "status.confirm.clear_alltime",
defaultValue: "Clear all-time stats? This cannot be undone.",
comment: "Confirmation dialog title for clearing all-time stats")
: String(localized: "status.confirm.clear_session",
defaultValue: "Clear session stats?",
comment: "Confirmation dialog title for clearing session stats"),
isPresented: $showingClearStatsConfirm,
titleVisibility: .visible
) {
Button(String(localized: "status.confirm.clear_button",
defaultValue: "Clear",
comment: "Destructive clear button inside stats clear confirmation dialogs"),
role: .destructive) {
Task { await vm.clearStats() }
}
Button(String(localized: "common.cancel",
defaultValue: "Cancel",
comment: "Common cancel button label"),
role: .cancel) {}
}
.confirmationDialog(
String(localized: "status.confirm.clear_ssd",
defaultValue: "Clear all SSD cache files? Loaded models will rebuild their KV cache on next prefill.",
comment: "Confirmation dialog title for clearing the SSD KV cache"),
isPresented: $showingClearSsdCacheConfirm,
titleVisibility: .visible
) {
Button(String(localized: "status.confirm.clear_button",
defaultValue: "Clear",
comment: "Destructive clear button inside stats clear confirmation dialogs"),
role: .destructive) {
Task { await vm.clearSsdCache() }
}
Button(String(localized: "common.cancel",
defaultValue: "Cancel",
comment: "Common cancel button label"),
role: .cancel) {}
}
.confirmationDialog(
String(localized: "status.confirm.clear_hot",
defaultValue: "Clear the in-memory KV cache for all loaded models? Subsequent requests will re-fault from SSD or recompute.",
comment: "Confirmation dialog title for clearing the hot (memory) KV cache"),
isPresented: $showingClearHotCacheConfirm,
titleVisibility: .visible
) {
Button(String(localized: "status.confirm.clear_button",
defaultValue: "Clear",
comment: "Destructive clear button inside stats clear confirmation dialogs"),
role: .destructive) {
Task { await vm.clearHotCache() }
}
Button(String(localized: "common.cancel",
defaultValue: "Cancel",
comment: "Common cancel button label"),
role: .cancel) {}
}
}
}
// MARK: - Runtime Cache section
/// Two-tier KV cache observability — memory (hot) tier + on-disk (SSD)
/// tier. Mirrors HTML _status.html's "Runtime Cache Observability"
/// panel. The memory tier rows hide when `hot_cache_max_bytes == 0`,
/// which signals the memory tier is disabled for the current run.
private struct RuntimeCacheSection: View {
let cache: StatsDTO.RuntimeCacheDTO?
let onClearSsdTap: () -> Void
let onClearHotTap: () -> Void
@Environment(\.omlxTheme) private var theme
private var hotTierEnabled: Bool {
(cache?.hotCacheMaxBytes ?? 0) > 0
}
var body: some View {
SectionHeader(String(localized: "status.section.runtime_cache",
defaultValue: "Runtime Cache",
comment: "Section header for the runtime cache observability panel"),
subtitle: subtitleText)
ListGroup {
if hotTierEnabled {
Row(label: String(localized: "status.runtime_cache.memory",
defaultValue: "Memory Cache",
comment: "Row label for the in-memory (hot) KV cache size gauge")) {
Text(memoryGaugeText)
.font(.omlxMono(12))
}
Row(label: String(localized: "status.runtime_cache.memory_entries",
defaultValue: "Memory Entries",
comment: "Row label for the number of in-memory KV cache entries")) {
Text(memoryEntriesText)
.font(.omlxMono(12))
}
}
Row(label: String(localized: "status.runtime_cache.files",
defaultValue: "Cache Files",
comment: "Row label for cache file count")) {
Text(fileCountText)
.font(.omlxMono(12))
}
Row(label: String(localized: "status.runtime_cache.size",
defaultValue: "Total Size",
comment: "Row label for total cache size on disk")) {
Text(sizeText)
.font(.omlxMono(12))
}
if let dir = cache?.ssdCacheDir, !dir.isEmpty {
Row(label: String(localized: "status.runtime_cache.location",
defaultValue: "Location",
comment: "Row label for the SSD cache directory path")) {
Text(dir)
.font(.omlxMono(11))
.foregroundStyle(theme.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 280, alignment: .trailing)
.help(dir)
}
}
FreeRow(isLast: true) {
HStack(spacing: 8) {
Spacer()
if hotTierEnabled {
Button(String(localized: "status.runtime_cache.clear_hot",
defaultValue: "Clear Memory Cache",
comment: "Destructive button to clear the in-memory KV cache"),
action: onClearHotTap)
.buttonStyle(.omlx(.destructive, size: .small))
.disabled((cache?.hotCacheEntries ?? 0) == 0)
}
Button(String(localized: "status.runtime_cache.clear_ssd",
defaultValue: "Clear SSD Cache",
comment: "Destructive button to clear all SSD cache files"),
action: onClearSsdTap)
.buttonStyle(.omlx(.destructive, size: .small))
.disabled((cache?.totalNumFiles ?? 0) == 0)
}
}
}
}
private var memoryGaugeText: String {
let used = cache?.hotCacheSizeBytes ?? 0
let cap = cache?.hotCacheMaxBytes ?? 0
return "\(formatByteCount(used)) / \(formatByteCount(cap))"
}
private var memoryEntriesText: String {
let n = cache?.hotCacheEntries ?? 0
return String(localized: "status.runtime_cache.memory_entries.count",
defaultValue: "Entries: \(n)",
comment: "Memory cache entry count; placeholder is the number of entries")
}
private var fileCountText: String {
guard let n = cache?.totalNumFiles else { return "—" }
return String(localized: "status.runtime_cache.file_count.count",
defaultValue: "Files: \(n)",
comment: "Cache file count; placeholder is the number of files")
}
private var sizeText: String {
guard let bytes = cache?.totalSizeBytes else { return "—" }
return formatByteCount(bytes)
}
private var subtitleText: String? {
guard let cache else { return nil }
let blocks = cache.effectiveBlockSizes ?? []
guard !blocks.isEmpty else { return nil }
let list = blocks.map(String.init).joined(separator: " · ")
return String(localized: "status.runtime_cache.block_size",
defaultValue: "Block size · \(list)",
comment: "Subtitle showing the effective KV cache block sizes; placeholder is a separator-joined list of numbers")
}
}
/// Bytes → human-readable string. Mirrors `formatByteCount` in
/// `omlx/admin/static/js/dashboard.js` — 1024-scaled, one decimal place.
private func formatByteCount(_ bytes: Int64) -> String {
let b = Double(max(0, bytes))
if b >= 1024 * 1024 * 1024 { return String(format: "%.1f GB", b / (1024 * 1024 * 1024)) }
if b >= 1024 * 1024 { return String(format: "%.1f MB", b / (1024 * 1024)) }
if b >= 1024 { return String(format: "%.1f KB", b / 1024) }
return "\(Int(b.rounded())) B"
}
// MARK: - Stat tiles
private struct StatTilesRow: View {
let stats: StatsDTO?
var body: some View {
HStack(alignment: .top, spacing: 10) {
StatTile(
label: String(localized: "status.tile.total",
defaultValue: "Total Prefill Tokens",
comment: "Stat tile label for total prefill tokens processed"),
value: stats.map { fmtNum($0.totalPromptTokens) } ?? "—"
)
StatTile(
label: String(localized: "status.tile.cached",
defaultValue: "Cached Tokens",
comment: "Stat tile label for cached tokens"),
value: stats.map { fmtNum($0.totalCachedTokens) } ?? "—"
)
StatTile(
label: String(localized: "status.tile.cache_efficiency",
defaultValue: "Cache Efficiency",
comment: "Stat tile label for cache efficiency percentage"),
value: stats.map { String(format: "%.1f%%", $0.cacheEfficiency) } ?? "—"
)
}
.padding(.horizontal, 14)
.padding(.bottom, 4)
}
}
private struct StatTile: View {
enum AccentRole { case neutral, success, warning, danger }
let label: String
let value: String
var sub: String?
var accentRole: AccentRole = .neutral
@Environment(\.omlxTheme) private var theme
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(.omlxText(11, weight: .medium))
.foregroundStyle(theme.textSecondary)
.textCase(.uppercase)
.kerning(0.6)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
Text(value)
.font(.omlxText(22, weight: .semibold))
.kerning(-0.5)
.foregroundStyle(accentColor)
.frame(maxWidth: .infinity)
if let sub {
Text(sub)
.font(.omlxText(11))
.foregroundStyle(theme.textTertiary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
}
.padding(16)
.frame(maxWidth: .infinity, minHeight: 96, alignment: .center)
.background(theme.groupBg)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
private var accentColor: Color {
switch accentRole {
case .neutral: return theme.text
case .success: return theme.greenDot
case .warning: return theme.amberDot
case .danger: return theme.redDot
}
}
}
private struct AverageSpeedTilesRow: View {
let stats: StatsDTO?
var body: some View {
HStack(alignment: .top, spacing: 10) {
StatTile(
label: String(localized: "status.speed.prompt_processing.tile",
defaultValue: "Prompt Processing (excl. cached)",
comment: "Tile label for average prompt-processing speed excluding cached tokens"),
value: stats.map { String(format: "%.1f tok/s", $0.avgPrefillTps) } ?? "—"
)
StatTile(
label: String(localized: "status.speed.token_generation",
defaultValue: "Token Generation",
comment: "Label for average token-generation speed"),
value: stats.map { String(format: "%.1f tok/s", $0.avgGenerationTps) } ?? "—"
)
}
.padding(.horizontal, 14)
.padding(.bottom, 4)
}
}
// MARK: - System rows
private struct GpuMemoryTrailing: View {
let stats: StatsDTO?
@Environment(\.omlxTheme) private var theme
var body: some View {
let used = stats?.activeModels.modelMemoryUsed
let max = stats?.activeModels.modelMemoryMax
HStack(spacing: 10) {
ProgressBar(progress: progress, tint: theme.blueDot)
.frame(width: 140)
Text(labelText(used: used, max: max))
.font(.omlxMono(11))
.foregroundStyle(theme.textSecondary)
.frame(minWidth: 110, alignment: .trailing)
}
}
private var progress: Double {
guard
let used = stats?.activeModels.modelMemoryUsed,
let max = stats?.activeModels.modelMemoryMax,
max > 0
else { return 0 }
return Double(used) / Double(max)
}
private func labelText(used: Int64?, max: Int64?) -> String {
guard let used, let max else { return "—" }
let u = SystemMetricsPoller.formatBytesAsGB(UInt64(Swift.max(0, used)))
let m = SystemMetricsPoller.formatBytesAsGB(UInt64(Swift.max(0, max)))
return "\(u) / \(m) GB"
}
}
private struct SystemRamTrailing: View {
let metrics: SystemMetricsPoller
@Environment(\.omlxTheme) private var theme
var body: some View {
HStack(spacing: 10) {
ProgressBar(progress: progress, tint: theme.text.opacity(0.7))
.frame(width: 140)
Text(labelText)
.font(.omlxMono(11))
.foregroundStyle(theme.textSecondary)
.frame(minWidth: 110, alignment: .trailing)
}
}
private var progress: Double {
guard let used = metrics.ramUsedBytes,
let total = metrics.ramTotalBytes,
total > 0
else { return 0 }
return Double(used) / Double(total)
}
private var labelText: String {
guard let used = metrics.ramUsedBytes,
let total = metrics.ramTotalBytes
else { return "—" }
let u = SystemMetricsPoller.formatBytesAsGB(used)
let t = SystemMetricsPoller.formatBytesAsGB(total)
return "\(u) / \(t) GB"
}
}
private struct ThermalTrailing: View {
let state: ProcessInfo.ThermalState
@Environment(\.omlxTheme) private var theme
var body: some View {
let severity = SystemMetricsPoller.severity(for: state)
HStack(spacing: 8) {
Circle()
.fill(color(for: severity))
.frame(width: 8, height: 8)
Text(SystemMetricsPoller.label(for: severity))
.font(.omlxText(12))
.foregroundStyle(theme.text)
}
}
private func color(for severity: ThermalSeverity) -> Color {
switch severity {
case .nominal: return theme.greenDot
case .fair, .serious: return theme.amberDot
case .critical: return theme.redDot
}
}
}
// MARK: - Active Now
private struct ActiveNowList: View {
let models: [StatsDTO.ActiveModelDTO]
@Environment(\.omlxTheme) private var theme
var body: some View {
ListGroup {
if models.isEmpty {
FreeRow(isLast: true) {
Text(String(localized: "status.active_now.empty",
defaultValue: "Server idle — no models loaded",
comment: "Empty-state text shown when the Active Now list has no entries"))
.font(.omlxText(12))
.foregroundStyle(theme.textTertiary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 12)
}
} else {
ForEach(Array(models.enumerated()), id: \.element.id) { index, model in
Row(label: model.id, isLast: index == models.count - 1) {
HStack(spacing: 12) {
modelStateBadge(for: model)
Text(model.estimatedSizeFormatted ?? "—")
.font(.omlxMono(11))
.foregroundStyle(theme.textSecondary)
.frame(minWidth: 60, alignment: .trailing)
}
}
}
}
}
}
@ViewBuilder
private func modelStateBadge(for model: StatsDTO.ActiveModelDTO) -> some View {
if model.isLoading == true {
StatusPill(status: .starting)
} else if (model.activeRequests ?? 0) > 0 {
StatusPill(status: .custom(color: theme.greenDot,
label: String(localized: "status.active_now.badge.generating",
defaultValue: "Generating",
comment: "Active Now badge: model currently generating tokens"),
fillBg: true))
} else if (model.waitingRequests ?? 0) > 0 {
StatusPill(status: .custom(color: theme.amberDot,
label: String(localized: "status.active_now.badge.waiting",
defaultValue: "Waiting",
comment: "Active Now badge: model has queued requests"),
fillBg: true))
} else {
StatusPill(status: .custom(color: theme.textTertiary,
label: String(localized: "status.active_now.badge.loaded",
defaultValue: "Loaded",
comment: "Active Now badge: model is loaded but idle"),
fillBg: true))
}
}
}
// MARK: - Updates section
private struct UpdatesSection: View {
// Reads UpdateController directly so SwiftUI redraws only for the
// observable update properties used by this section.
let updates: UpdateController
@Environment(\.omlxTheme) private var theme
var body: some View {
ListGroup {
FreeRow {
HStack(spacing: 12) {
Squircle(systemSymbol: "arrow.down.circle.fill",
size: 32,
gradient: SquircleGradient.update)
VStack(alignment: .leading, spacing: 2) {
primaryLine
secondaryLine
}
Spacer(minLength: 8)
actionButton
}
}
Row(
label: String(localized: "status.updates.channel",
defaultValue: "Update Channel",
comment: "Row label for selecting the app update channel"),
sublabel: String(localized: "status.updates.channel.sub",
defaultValue: "Stable, release candidate, or dev builds",
comment: "Sublabel for the update channel picker")
) {
Popup(
selection: Binding(
get: { updates.channel },
set: { updates.channel = $0 }
),
width: 190,
options: UpdateChannel.allCases.map { ($0, $0.displayName) }
)
}
Row(
label: String(localized: "status.updates.auto_check",
defaultValue: "Automatically Check",
comment: "Row label for the auto-check updates toggle"),
sublabel: String(localized: "status.updates.auto_check.sub",
defaultValue: "Look for updates daily in the background",
comment: "Sublabel for the auto-check toggle")
) {
Toggle("", isOn: Binding(
get: { updates.autoCheck },
set: { updates.autoCheck = $0 }
))
.labelsHidden()
.toggleStyle(.switch)
}
Row(
label: String(localized: "status.updates.auto_download",
defaultValue: "Notify About Updates",
comment: "Row label for the automatic update notification toggle"),
sublabel: String(localized: "status.updates.auto_download.sub",
defaultValue: "Show release notes before downloading in the background",
comment: "Sublabel for the automatic update notification toggle"),
isLast: true
) {
Toggle("", isOn: Binding(
get: { updates.autoNotify },
set: { updates.autoNotify = $0 }
))
.labelsHidden()
.toggleStyle(.switch)
}
}
}
@ViewBuilder
private var primaryLine: some View {
switch updates.state {
case .checking:
Text(String(localized: "status.updates.checking_primary",
defaultValue: "Checking for updates…",
comment: "Primary update status line while a check is in progress"))
.font(.omlxText(13, weight: .medium))
.foregroundStyle(theme.text)
case .available(let upd), .ready(let upd):
Text(String(localized: "status.updates.available_primary",
defaultValue: "oMLX \(upd.version) is available",
comment: "Primary update status line when a new version is available; placeholder is the version string"))
.font(.omlxText(13, weight: .medium))
.foregroundStyle(theme.text)
case .downloading:
Text(String(localized: "status.updates.downloading_primary",
defaultValue: "Downloading update…",
comment: "Primary update status line while the new build is downloading"))
.font(.omlxText(13, weight: .medium))
.foregroundStyle(theme.text)
case .idle:
Text(updates.lastError == nil
? String(localized: "status.updates.up_to_date_primary",
defaultValue: "oMLX is up to date",
comment: "Primary update status line when no update is available")
: String(localized: "status.updates.error_primary",
defaultValue: "Update check failed",
comment: "Primary update status line when checking for updates failed"))
.font(.omlxText(13, weight: .medium))
.foregroundStyle(theme.text)
}
}
@ViewBuilder
private var secondaryLine: some View {
switch updates.state {
case .checking:
Text(String(localized: "status.updates.checking_secondary",
defaultValue: "Checking GitHub releases…",
comment: "Secondary update status line while a check is in progress"))
.font(.omlxText(11))
.foregroundStyle(theme.textSecondary)
case .available(let upd):
Text(String(localized: "status.updates.available_secondary",
defaultValue: "Ready to download · \(upd.sizeText ?? "—")",
comment: "Secondary update status line when a new version is available; placeholder is the download size or em dash"))
.font(.omlxText(11))
.foregroundStyle(theme.textSecondary)
case .downloading(let pct):
Text(String(localized: "status.updates.downloading_secondary",
defaultValue: "Downloading · \(pct)%",
comment: "Secondary update status line during download; placeholder is the percent complete"))
.font(.omlxText(11))
.foregroundStyle(theme.textSecondary)
case .ready(let upd):
Text(String(localized: "status.updates.ready_secondary",
defaultValue: "\(upd.version) is ready to install",
comment: "Secondary update status line once the staged bundle is ready; placeholder is the version"))
.font(.omlxText(11))
.foregroundStyle(theme.textSecondary)
case .idle(let lastChecked):
Text(updates.lastError ?? lastCheckedText(lastChecked))
.font(.omlxText(11))
.foregroundStyle(theme.textSecondary)
}
}
@ViewBuilder
private var actionButton: some View {
switch updates.state {
case .available, .ready:
Button(String(localized: "status.updates.install",
defaultValue: "Review Update",
comment: "Updates action button to review release notes before installing")) {
updates.requestUpdateConfirmation()
}
.buttonStyle(.omlx(.primary, size: .small))
case .downloading(let pct):
Button(String(localized: "status.updates.downloading_button",
defaultValue: "Downloading… \(pct)%",
comment: "Updates action button label while the download is in progress; placeholder is the percent complete")) { }
.buttonStyle(.omlx(.normal, size: .small))
.disabled(true)
case .checking:
Button(String(localized: "status.updates.checking_button",
defaultValue: "Checking…",
comment: "Updates action button label while a check is in progress")) { }
.buttonStyle(.omlx(.normal, size: .small))
.disabled(true)
case .idle:
Button(String(localized: "status.updates.check",
defaultValue: "Check Now",
comment: "Updates action button label to trigger a manual update check")) {
updates.checkForUpdates()
}
.buttonStyle(.omlx(.normal, size: .small))
}
}
private func lastCheckedText(_ date: Date?) -> String {
guard let date else {
return String(localized: "status.updates.never_checked",
defaultValue: "Never checked",
comment: "Secondary update line when there is no prior check timestamp")
}
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
let calendar = Calendar.current
if calendar.isDateInToday(date) {
return String(localized: "status.updates.last_checked_today",
defaultValue: "Last checked today at \(formatter.string(from: date))",
comment: "Last-checked text when the last check was today; placeholder is the local time")
}
formatter.dateStyle = .medium
return String(localized: "status.updates.last_checked_date",
defaultValue: "Last checked \(formatter.string(from: date))",
comment: "Last-checked text when the last check was before today; placeholder is the formatted date and time")
}
}
// MARK: - Helpers
private func fmtNum(_ n: Int) -> String {
let v = Double(n)
if v >= 1e9 { return String(format: "%.2fB", v / 1e9) }
if v >= 1e6 { return String(format: "%.2fM", v / 1e6) }
if v >= 1e3 { return String(format: "%.1fK", v / 1e3) }
return String(n)
}