diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5625898 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,238 @@ +# Claude Code Instructions for Container Manager + +## Project Overview + +This is a native macOS container management application built with SwiftUI. It supports Apple's container tool and provides both menu bar and desktop app modes. + +## Code Style & Conventions + +### SwiftUI Best Practices + +#### Label Wrapping Prevention ⚠️ CRITICAL + +**Always prevent label wrapping in horizontal layouts:** + +When using SwiftUI controls with labels (Picker, Toggle, Button, etc.) in tight horizontal layouts, apply these modifiers: + +```swift +// Segmented Picker Pattern +Picker("Label Text", selection: $binding) { + ForEach(options) { option in + Text(option).tag(option) + } +} +.pickerStyle(.segmented) +.labelsHidden() // Hide built-in label to prevent wrapping +.fixedSize() // Prevent control compression +.frame(width: XXX) // Set explicit width + +// Toggle Pattern +Toggle("Label Text", isOn: $binding) + .toggleStyle(.switch) + .fixedSize() // Prevent label wrapping + .help("Tooltip text") +``` + +**When to apply:** +- ✅ Segmented pickers in toolbars +- ✅ Toggles in horizontal layouts +- ✅ Any control in constrained horizontal space +- ✅ Controls where label might wrap to multiple lines + +**Common locations:** +- Toolbar controls +- Inspector panels +- Settings views +- Modal headers + +### Thread Safety & Performance + +#### Background Processing + +**Always run blocking operations on background threads:** + +```swift +// ✅ Good - Non-blocking process execution +private func executeCommand() async -> String? { + return await withCheckedContinuation { continuation in + Task.detached { + let process = Process() + // ... setup process ... + process.run() + process.waitUntilExit() // Blocks background thread only + continuation.resume(returning: result) + } + } +} + +// ❌ Bad - Blocks main thread +private func executeCommand() async -> String? { + let process = Process() + process.run() + process.waitUntilExit() // Blocks current thread! + return result +} +``` + +**Thread-safe data access:** + +```swift +// Use NSLock for simple synchronization +private let lock = NSLock() +nonisolated(unsafe) private var sharedData: [String: Data] = [:] + +func updateData() { + lock.lock() + defer { lock.unlock() } + // Safely access sharedData +} +``` + +### Naming Conventions + +- **Views**: PascalCase (e.g., `ContainerListView`, `StatsView`) +- **Properties**: camelCase (e.g., `containerMonitor`, `isLoading`) +- **Methods**: camelCase with verb prefix (e.g., `startContainer`, `fetchLogs`) +- **Constants**: camelCase (e.g., `maxRetries`, `defaultTimeout`) + +### File Organization + +``` +container-manager/ +├── container-manager/ # Main app target +│ ├── *App.swift # App entry point +│ ├── *SystemMonitor.swift # Core business logic +│ ├── ContentView.swift # Menu bar view +│ └── Assets.xcassets +├── Views/ # UI components (root level) +│ ├── ContainerListView.swift +│ ├── StatsView.swift +│ └── ... +├── Tests/ +└── docs/ # All documentation +``` + +## Testing + +### When to Write Tests + +- New business logic in `ContainerSystemMonitor` +- Data models and parsers +- Network/Volume management features +- Complex algorithms (stats calculations, parsing) + +### Testing Framework + +Use the Testing framework (not XCTest) for new tests: + +```swift +import Testing + +@Test("Container stats parsing works correctly") +func testStatsParser() async throws { + // Test implementation +} +``` + +## Git Commit Messages + +Follow conventional commits format: + +``` +feat: implement stats collection for Apple container tool +fix: prevent label wrapping in stats view picker +docs: update architecture documentation +refactor: extract stats parsing to separate method +``` + +**Always include Co-Authored-By for AI assistance:** + +``` +feat: add detailed stats visualization + +- Add enhanced charts with gradients +- Implement network I/O legend +- Add block I/O chart + +Co-Authored-By: Claude Sonnet 4.5 +``` + +## Common Patterns + +### Stats Collection + +- Collect every 10 seconds in background +- Store up to 6 hours of history (2160 data points) +- CPU calculated from cumulative microseconds delta +- All metrics thread-safe with NSLock + +### Container Operations + +```swift +// Pattern for container commands +private func performContainerOperation(command: String, containerName: String) async -> Bool { + await MainActor.run { isOperating = true } + defer { Task { await MainActor.run { isOperating = false } } } + + // Execute in background... +} +``` + +### View Modifiers + +```swift +// Common modifier chain for cards +.padding() +.background(Color(nsColor: .controlBackgroundColor)) +.cornerRadius(12) +``` + +## Dependencies + +- macOS 14.0+ +- SwiftUI with Charts framework +- Apple's `container` CLI tool +- No external package dependencies + +## Documentation + +All documentation lives in `docs/`: +- Architecture docs +- Phase summaries +- Development guides +- Test references + +Keep README.md focused on getting started and features. + +## Performance Guidelines + +- Stats collection must not block UI (use Task.detached) +- Process execution must use background threads +- Published properties trigger UI updates - minimize changes +- Use `.chartXAxis(.hidden)` for compact charts to improve render performance + +## Accessibility + +- Always provide `.help()` tooltips for icon-only buttons +- Use SF Symbols for consistent iconography +- Ensure good contrast ratios +- Label all interactive elements + +## Common Issues & Solutions + +### Issue: Beachball/UI Freezing +**Solution:** Move `Process.waitUntilExit()` to `Task.detached` + +### Issue: Label wrapping in pickers/toggles +**Solution:** Add `.labelsHidden()` and `.fixedSize()` + +### Issue: Stats not updating +**Solution:** Check stats collection is running and publishing to @Published properties + +### Issue: Memory leaks with closures +**Solution:** Use `[weak self]` in escaping closures and Tasks + +--- + +**Last Updated:** 2026-02-07 +**Project Phase:** Stats Visualization Complete diff --git a/StatsView.swift b/StatsView.swift index 72fd156..afad0da 100644 --- a/StatsView.swift +++ b/StatsView.swift @@ -12,6 +12,7 @@ struct StatsView: View { @EnvironmentObject var containerMonitor: ContainerSystemMonitor @State private var selectedContainer: String? @State private var timeRange: TimeRange = .fifteenMinutes + @State private var showingDetailedStats = false var body: some View { VStack(spacing: 0) { @@ -29,6 +30,8 @@ struct StatsView: View { } } .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() .frame(width: 250) } .padding() @@ -41,6 +44,16 @@ struct StatsView: View { statsContent } } + .sheet(isPresented: $showingDetailedStats) { + if let containerName = selectedContainer { + DetailedStatsView( + containerName: containerName, + timeRange: $timeRange + ) + .environmentObject(containerMonitor) + .frame(minWidth: 800, minHeight: 600) + } + } } // MARK: - Stats Content @@ -54,6 +67,7 @@ struct StatsView: View { // Per-container stats ForEach(runningContainers) { container in containerStatsCard(for: container) + .id("\(container.id)-\(timeRange.rawValue)") } } .padding() @@ -91,8 +105,26 @@ struct StatsView: View { return String(format: "%.2f MB/s", totalMB) } + // MARK: - Formatters + + private func formatBytes(_ bytes: Double) -> String { + let kb = bytes / 1024 + let mb = kb / 1024 + let gb = mb / 1024 + + if gb >= 1 { + return String(format: "%.1f GB", gb) + } else if mb >= 1 { + return String(format: "%.1f MB", mb) + } else if kb >= 1 { + return String(format: "%.1f KB", kb) + } else { + return String(format: "%.0f B", bytes) + } + } + // MARK: - Container Stats Helpers - + private func containerCPUValue(for containerName: String) -> String { guard let history = containerMonitor.statsCollector?.containerStats[containerName], let latest = history.latestSnapshot() else { @@ -183,9 +215,10 @@ struct StatsView: View { } Spacer() - + Button("Details") { - // TODO: Open detailed stats + selectedContainer = container.name + showingDetailedStats = true } .controlSize(.small) } @@ -206,20 +239,7 @@ struct StatsView: View { } if let data = containerMonitor.statsCollector?.containerStats[container.name]?.dataPoints(for: timeRange), !data.isEmpty { - Chart(data) { point in - LineMark( - x: .value("Time", point.timestamp), - y: .value("CPU %", point.cpuPercent) - ) - .foregroundStyle(.green) - .interpolationMethod(.catmullRom) - } - .chartYScale(domain: 0...100) - .chartXAxis(.hidden) - .chartYAxis { - AxisMarks(position: .leading, values: [0, 50, 100]) - } - .frame(height: 60) + CPUChart(data: data, timeRange: timeRange) } else { Rectangle() .fill(Color.green.opacity(0.1)) @@ -246,19 +266,7 @@ struct StatsView: View { } if let data = containerMonitor.statsCollector?.containerStats[container.name]?.dataPoints(for: timeRange), !data.isEmpty { - Chart(data) { point in - AreaMark( - x: .value("Time", point.timestamp), - y: .value("Memory MB", point.memoryUsageMB) - ) - .foregroundStyle(.orange.gradient.opacity(0.6)) - .interpolationMethod(.catmullRom) - } - .chartXAxis(.hidden) - .chartYAxis { - AxisMarks(position: .leading) - } - .frame(height: 60) + MemoryChart(data: data, timeRange: timeRange) } else { Rectangle() .fill(Color.orange.opacity(0.1)) @@ -278,36 +286,38 @@ struct StatsView: View { Text("Network I/O") .font(.caption) .foregroundStyle(.secondary) + Spacer() + + // Legend + HStack(spacing: 12) { + HStack(spacing: 4) { + Circle() + .fill(.blue) + .frame(width: 6, height: 6) + Text("RX") + .font(.caption2) + .foregroundStyle(.secondary) + } + + HStack(spacing: 4) { + RoundedRectangle(cornerRadius: 1) + .fill(.purple) + .frame(width: 12, height: 2) + Text("TX") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + Text(containerNetworkValue(for: container.name)) .font(.caption) .fontWeight(.semibold) .foregroundStyle(.purple) } - + if let data = containerMonitor.statsCollector?.containerStats[container.name]?.dataPoints(for: timeRange), !data.isEmpty { - Chart { - ForEach(data) { point in - LineMark( - x: .value("Time", point.timestamp), - y: .value("RX MB", point.networkRxMB) - ) - .foregroundStyle(.blue) - .lineStyle(StrokeStyle(lineWidth: 2)) - - LineMark( - x: .value("Time", point.timestamp), - y: .value("TX MB", point.networkTxMB) - ) - .foregroundStyle(.purple) - .lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 3])) - } - } - .chartXAxis(.hidden) - .chartYAxis { - AxisMarks(position: .leading) - } - .frame(height: 60) + NetworkIOChart(data: data, timeRange: timeRange) } else { Rectangle() .fill(Color.purple.opacity(0.1)) @@ -385,6 +395,538 @@ struct StatCard: View { } } +// MARK: - Detailed Stats View + +struct DetailedStatsView: View { + @EnvironmentObject var containerMonitor: ContainerSystemMonitor + @Environment(\.dismiss) var dismiss + let containerName: String + @Binding var timeRange: TimeRange + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Detailed Statistics") + .font(.title2) + .fontWeight(.bold) + + Text(containerName) + .font(.headline) + .foregroundStyle(.secondary) + } + + Spacer() + + Picker("Time Range", selection: $timeRange) { + ForEach(TimeRange.allCases, id: \.self) { range in + Text(range.rawValue).tag(range) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + .frame(width: 250) + + Button("Close") { + dismiss() + } + } + .padding() + + Divider() + + ScrollView { + VStack(spacing: 24) { + // CPU Chart (Large) + chartSection( + title: "CPU Usage", + icon: "cpu", + color: .green + ) { + if let data = containerMonitor.statsCollector?.containerStats[containerName]?.dataPoints(for: timeRange), !data.isEmpty { + CPUChart(data: data, timeRange: timeRange, height: 200, showXAxis: true) + } else { + emptyChartPlaceholder + } + } + .id("cpu-section-\(timeRange.rawValue)") + + // Memory Chart (Large) + chartSection( + title: "Memory Usage", + icon: "memorychip", + color: .orange + ) { + if let data = containerMonitor.statsCollector?.containerStats[containerName]?.dataPoints(for: timeRange), !data.isEmpty { + MemoryChart(data: data, timeRange: timeRange, height: 200, showXAxis: true) + } else { + emptyChartPlaceholder + } + } + .id("memory-section-\(timeRange.rawValue)") + + // Network I/O Chart (Large) + chartSection( + title: "Network I/O", + icon: "network", + color: .purple + ) { + if let data = containerMonitor.statsCollector?.containerStats[containerName]?.dataPoints(for: timeRange), !data.isEmpty { + NetworkIOChart(data: data, timeRange: timeRange, height: 200, showXAxis: true) + + // Legend + HStack(spacing: 24) { + HStack(spacing: 8) { + Circle() + .fill(.blue) + .frame(width: 10, height: 10) + Text("Received (RX)") + .font(.caption) + } + + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 2) + .fill(.purple) + .frame(width: 20, height: 3) + Text("Transmitted (TX)") + .font(.caption) + } + } + .padding(.top, 8) + } else { + emptyChartPlaceholder + } + } + .id("network-section-\(timeRange.rawValue)") + + // Block I/O Chart (Large) + chartSection( + title: "Block I/O", + icon: "internaldrive", + color: .cyan + ) { + if let data = containerMonitor.statsCollector?.containerStats[containerName]?.dataPoints(for: timeRange), !data.isEmpty { + BlockIOChart(data: data, timeRange: timeRange) + + // Legend + HStack(spacing: 24) { + HStack(spacing: 8) { + Circle() + .fill(.cyan) + .frame(width: 10, height: 10) + Text("Read") + .font(.caption) + } + + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 2) + .fill(.pink) + .frame(width: 20, height: 3) + Text("Write") + .font(.caption) + } + } + .padding(.top, 8) + } else { + emptyChartPlaceholder + } + } + .id("blockio-section-\(timeRange.rawValue)") + + // Statistics Summary + if let history = containerMonitor.statsCollector?.containerStats[containerName] { + statsSummarySection(history: history) + } + } + .padding() + } + } + } + + @ViewBuilder + private func chartSection( + title: String, + icon: String, + color: Color, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: icon) + .foregroundStyle(color) + .imageScale(.large) + + Text(title) + .font(.headline) + + Spacer() + } + + content() + } + .padding() + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(12) + } + + private var emptyChartPlaceholder: some View { + Rectangle() + .fill(Color.secondary.opacity(0.1)) + .frame(height: 200) + .overlay( + Text("No data available") + .foregroundStyle(.secondary) + ) + } + + @ViewBuilder + private func statsSummarySection(history: ContainerStatsHistory) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("Statistics Summary") + .font(.headline) + + HStack(spacing: 16) { + summaryCard( + title: "Avg CPU", + value: String(format: "%.1f%%", history.averageCPU(for: timeRange)), + color: .green + ) + + summaryCard( + title: "Avg Memory", + value: String(format: "%.0f MB", history.averageMemory(for: timeRange)), + color: .orange + ) + + summaryCard( + title: "Peak Memory", + value: String(format: "%.0f MB", history.peakMemory(for: timeRange)), + color: .red + ) + + let throughput = history.networkThroughput(for: timeRange) + summaryCard( + title: "Network", + value: "↓ \(formatBytes(throughput.rx))/s\n↑ \(formatBytes(throughput.tx))/s", + color: .purple + ) + } + } + .padding() + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(12) + } + + @ViewBuilder + private func summaryCard(title: String, value: String, color: Color) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + + Text(value) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(color) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(color.opacity(0.1)) + .cornerRadius(8) + } + + private func formatBytes(_ bytes: Double) -> String { + let kb = bytes / 1024 + let mb = kb / 1024 + let gb = mb / 1024 + + if gb >= 1 { + return String(format: "%.1f GB", gb) + } else if mb >= 1 { + return String(format: "%.1f MB", mb) + } else if kb >= 1 { + return String(format: "%.1f KB", kb) + } else { + return String(format: "%.0f B", bytes) + } + } +} + +// MARK: - Reusable Chart Components + +struct CPUChart: View { + let data: [ContainerStatsSnapshot] + let timeRange: TimeRange + let height: CGFloat + let showXAxis: Bool + + init(data: [ContainerStatsSnapshot], timeRange: TimeRange, height: CGFloat = 60, showXAxis: Bool = false) { + self.data = data + self.timeRange = timeRange + self.height = height + self.showXAxis = showXAxis + } + + var body: some View { + Chart(data) { point in + LineMark( + x: .value("Time", point.timestamp), + y: .value("CPU %", point.cpuPercent) + ) + .foregroundStyle(.green.gradient) + .lineStyle(StrokeStyle(lineWidth: showXAxis ? 3 : 2)) + + AreaMark( + x: .value("Time", point.timestamp), + y: .value("CPU %", point.cpuPercent) + ) + .foregroundStyle(.green.gradient.opacity(showXAxis ? 0.15 : 0.1)) + } + .chartYScale(domain: 0...100) + .chartXAxis { + if showXAxis { + AxisMarks(values: .automatic(desiredCount: 8)) { value in + AxisValueLabel(format: .dateTime.hour().minute()) + AxisGridLine() + } + } + } + .chartYAxis { + if showXAxis { + AxisMarks(position: .leading, values: [0, 25, 50, 75, 100]) { value in + AxisValueLabel { + if let intValue = value.as(Int.self) { + Text("\(intValue)%") + } + } + AxisGridLine() + } + } else { + AxisMarks(position: .leading, values: [0, 50, 100]) { value in + AxisValueLabel { + if let intValue = value.as(Int.self) { + Text("\(intValue)%") + .font(.caption2) + } + } + AxisGridLine() + } + } + } + .frame(height: height) + .id("cpu-\(timeRange.rawValue)-\(showXAxis)") + } +} + +struct MemoryChart: View { + let data: [ContainerStatsSnapshot] + let timeRange: TimeRange + let height: CGFloat + let showXAxis: Bool + + init(data: [ContainerStatsSnapshot], timeRange: TimeRange, height: CGFloat = 60, showXAxis: Bool = false) { + self.data = data + self.timeRange = timeRange + self.height = height + self.showXAxis = showXAxis + } + + var body: some View { + Chart(data) { point in + AreaMark( + x: .value("Time", point.timestamp), + y: .value("Memory MB", point.memoryUsageMB) + ) + .foregroundStyle(.orange.gradient.opacity(0.3)) + + LineMark( + x: .value("Time", point.timestamp), + y: .value("Memory MB", point.memoryUsageMB) + ) + .foregroundStyle(.orange) + .lineStyle(StrokeStyle(lineWidth: showXAxis ? 3 : 2)) + } + .chartXAxis { + if showXAxis { + AxisMarks(values: .automatic(desiredCount: 8)) { value in + AxisValueLabel(format: .dateTime.hour().minute()) + AxisGridLine() + } + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisValueLabel { + if let doubleValue = value.as(Double.self) { + if showXAxis { + Text("\(Int(doubleValue)) MB") + } else { + Text("\(Int(doubleValue))") + .font(.caption2) + } + } + } + AxisGridLine() + } + } + .frame(height: height) + .id("memory-\(timeRange.rawValue)-\(showXAxis)") + } +} + +struct NetworkIOChart: View { + let data: [ContainerStatsSnapshot] + let timeRange: TimeRange + let height: CGFloat + let showXAxis: Bool + + init(data: [ContainerStatsSnapshot], timeRange: TimeRange, height: CGFloat = 60, showXAxis: Bool = false) { + self.data = data + self.timeRange = timeRange + self.height = height + self.showXAxis = showXAxis + } + + private func formatBytes(_ bytes: Double) -> String { + let kb = bytes / 1024 + let mb = kb / 1024 + let gb = mb / 1024 + + if gb >= 1 { + return String(format: "%.1f GB", gb) + } else if mb >= 1 { + return String(format: "%.1f MB", mb) + } else if kb >= 1 { + return String(format: "%.1f KB", kb) + } else { + return String(format: "%.0f B", bytes) + } + } + + var body: some View { + Chart { + ForEach(data) { point in + // RX (Download) + LineMark( + x: .value("Time", point.timestamp), + y: .value(showXAxis ? "Bytes" : "MB", showXAxis ? Double(point.networkRxBytes) : point.networkRxMB) + ) + .foregroundStyle(.blue) + .lineStyle(StrokeStyle(lineWidth: showXAxis ? 3 : 2)) + + AreaMark( + x: .value("Time", point.timestamp), + y: .value(showXAxis ? "Bytes" : "MB", showXAxis ? Double(point.networkRxBytes) : point.networkRxMB) + ) + .foregroundStyle(.blue.gradient.opacity(showXAxis ? 0.15 : 0.1)) + + // TX (Upload) + LineMark( + x: .value("Time", point.timestamp), + y: .value(showXAxis ? "Bytes" : "MB", showXAxis ? Double(point.networkTxBytes) : point.networkTxMB) + ) + .foregroundStyle(.purple) + .lineStyle(StrokeStyle(lineWidth: showXAxis ? 3 : 2, dash: showXAxis ? [8, 4] : [5, 3])) + } + } + .chartXAxis { + if showXAxis { + AxisMarks(values: .automatic(desiredCount: 8)) { value in + AxisValueLabel(format: .dateTime.hour().minute()) + AxisGridLine() + } + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisValueLabel { + if let doubleValue = value.as(Double.self) { + if showXAxis { + Text(formatBytes(doubleValue)) + } else { + Text(formatBytes(doubleValue * 1024 * 1024)) + .font(.caption2) + } + } + } + AxisGridLine() + } + } + .frame(height: height) + .id("network-\(timeRange.rawValue)-\(showXAxis)") + } +} + +struct BlockIOChart: View { + let data: [ContainerStatsSnapshot] + let timeRange: TimeRange + let height: CGFloat + + init(data: [ContainerStatsSnapshot], timeRange: TimeRange, height: CGFloat = 200) { + self.data = data + self.timeRange = timeRange + self.height = height + } + + private func formatBytes(_ bytes: Double) -> String { + let kb = bytes / 1024 + let mb = kb / 1024 + let gb = mb / 1024 + + if gb >= 1 { + return String(format: "%.1f GB", gb) + } else if mb >= 1 { + return String(format: "%.1f MB", mb) + } else if kb >= 1 { + return String(format: "%.1f KB", kb) + } else { + return String(format: "%.0f B", bytes) + } + } + + var body: some View { + Chart { + ForEach(data) { point in + // Read + LineMark( + x: .value("Time", point.timestamp), + y: .value("Bytes", point.blockReadBytes) + ) + .foregroundStyle(.cyan) + .lineStyle(StrokeStyle(lineWidth: 3)) + + // Write + LineMark( + x: .value("Time", point.timestamp), + y: .value("Bytes", point.blockWriteBytes) + ) + .foregroundStyle(.pink) + .lineStyle(StrokeStyle(lineWidth: 3, dash: [8, 4])) + } + } + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 8)) { value in + AxisValueLabel(format: .dateTime.hour().minute()) + AxisGridLine() + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisValueLabel { + if let doubleValue = value.as(Double.self) { + Text(formatBytes(doubleValue)) + } + } + AxisGridLine() + } + } + .frame(height: height) + .id("blockio-\(timeRange.rawValue)") + } +} + // MARK: - Preview #Preview {