diff --git a/ios-app/SlurmManager/SlurmManager/Components/ConnectionStatusBar.swift b/ios-app/SlurmManager/SlurmManager/Components/ConnectionStatusBar.swift new file mode 100644 index 0000000..35f54e3 --- /dev/null +++ b/ios-app/SlurmManager/SlurmManager/Components/ConnectionStatusBar.swift @@ -0,0 +1,354 @@ +import SwiftUI +import Combine + +// MARK: - Connection Status Bar +/// A banner that shows connection status and offline mode indicator +struct ConnectionStatusBar: View { + @StateObject private var appState = AppState.shared + @State private var isVisible = false + @State private var lastUpdateTime = Date() + + var body: some View { + Group { + if shouldShowBanner { + bannerContent + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: shouldShowBanner) + .onChange(of: appState.connectionStatus) { newStatus in + withAnimation { + // Show banner when status changes + isVisible = true + } + + // Hide success banners after 3 seconds + if newStatus == .connected { + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + withAnimation { + isVisible = false + } + } + } + } + } + + var shouldShowBanner: Bool { + switch appState.connectionStatus { + case .disconnected, .error: + return true + case .connecting: + return true + case .connected: + return isVisible + } + } + + var bannerContent: some View { + HStack(spacing: 12) { + // Status icon + statusIcon + + // Status text + VStack(alignment: .leading, spacing: 2) { + Text(statusTitle) + .font(.subheadline) + .fontWeight(.semibold) + + Text(statusSubtitle) + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + + Spacer() + + // Action button or progress + if case .connecting = appState.connectionStatus { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(0.8) + } else if case .disconnected = appState.connectionStatus { + Button(action: reconnect) { + Text("Retry") + .font(.caption) + .fontWeight(.semibold) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.white.opacity(0.2)) + .cornerRadius(6) + } + } + } + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(backgroundColor) + .cornerRadius(12) + .shadow(color: .black.opacity(0.1), radius: 5, y: 2) + .padding(.horizontal) + .padding(.top, 8) + } + + @ViewBuilder + var statusIcon: some View { + ZStack { + Circle() + .fill(Color.white.opacity(0.2)) + .frame(width: 36, height: 36) + + switch appState.connectionStatus { + case .connected: + Image(systemName: "checkmark.circle.fill") + .font(.title3) + case .connecting: + Image(systemName: "wifi") + .font(.title3) + case .disconnected: + Image(systemName: "wifi.slash") + .font(.title3) + case .error: + Image(systemName: "exclamationmark.triangle.fill") + .font(.title3) + } + } + } + + var statusTitle: String { + switch appState.connectionStatus { + case .connected: + return "Connected" + case .connecting: + return "Connecting..." + case .disconnected: + return "Disconnected" + case .error(let message): + return message.isEmpty ? "Connection Error" : message + } + } + + var statusSubtitle: String { + switch appState.connectionStatus { + case .connected: + return "Real-time updates active" + case .connecting: + return "Establishing connection" + case .disconnected: + return "Tap to reconnect" + case .error: + return "Check your network and server" + } + } + + var backgroundColor: Color { + switch appState.connectionStatus { + case .connected: + return .green + case .connecting: + return .blue + case .disconnected: + return .orange + case .error: + return .red + } + } + + private func reconnect() { + HapticManager.impact(.medium) + WebSocketManager.shared.connect() + } +} + +// MARK: - Compact Connection Status +/// A smaller connection indicator for toolbar/navigation bar +struct CompactConnectionStatus: View { + @StateObject private var appState = AppState.shared + @State private var isAnimating = false + + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(statusColor) + .frame(width: 8, height: 8) + .scaleEffect(isAnimating ? 1.2 : 1.0) + .animation( + statusColor == .green + ? nil + : .easeInOut(duration: 0.8).repeatForever(autoreverses: true), + value: isAnimating + ) + + if shouldShowLabel { + Text(statusLabel) + .font(.caption2) + .foregroundColor(.secondary) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(statusColor.opacity(0.1)) + .cornerRadius(8) + .onAppear { + if appState.connectionStatus != .connected { + isAnimating = true + } + } + .onChange(of: appState.connectionStatus) { newStatus in + isAnimating = newStatus != .connected + } + } + + var statusColor: Color { + switch appState.connectionStatus { + case .connected: return .green + case .connecting: return .blue + case .disconnected: return .orange + case .error: return .red + } + } + + var shouldShowLabel: Bool { + appState.connectionStatus != .connected + } + + var statusLabel: String { + switch appState.connectionStatus { + case .connected: return "" + case .connecting: return "Connecting" + case .disconnected: return "Offline" + case .error: return "Error" + } + } +} + +// MARK: - Offline Mode Banner +struct OfflineModeBanner: View { + @State private var showDetails = false + + var body: some View { + VStack(spacing: 0) { + Button(action: { + withAnimation(.spring()) { + showDetails.toggle() + } + }) { + HStack(spacing: 10) { + Image(systemName: "icloud.slash") + .font(.subheadline) + + Text("Working Offline") + .font(.subheadline) + .fontWeight(.medium) + + Spacer() + + Image(systemName: "chevron.down") + .font(.caption) + .rotationEffect(.degrees(showDetails ? 180 : 0)) + } + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color.orange) + } + + if showDetails { + VStack(alignment: .leading, spacing: 8) { + Text("You're viewing cached data. Some features may be unavailable:") + .font(.caption) + + HStack(spacing: 4) { + Image(systemName: "xmark.circle.fill") + .font(.caption2) + .foregroundColor(.red) + Text("Real-time updates") + .font(.caption) + } + + HStack(spacing: 4) { + Image(systemName: "xmark.circle.fill") + .font(.caption2) + .foregroundColor(.red) + Text("Job submission") + .font(.caption) + } + + HStack(spacing: 4) { + Image(systemName: "checkmark.circle.fill") + .font(.caption2) + .foregroundColor(.green) + Text("View cached jobs") + .font(.caption) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.1)) + .transition(.asymmetric( + insertion: .push(from: .top).combined(with: .opacity), + removal: .opacity + )) + } + } + } +} + +// MARK: - Last Sync Indicator +struct LastSyncIndicator: View { + let lastSyncTime: Date? + + var body: some View { + if let lastSync = lastSyncTime { + HStack(spacing: 4) { + Image(systemName: "arrow.clockwise") + .font(.caption2) + + Text("Updated \(formattedTime(lastSync))") + .font(.caption2) + } + .foregroundColor(.secondary) + } + } + + private func formattedTime(_ date: Date) -> String { + let interval = Date().timeIntervalSince(date) + + if interval < 60 { + return "just now" + } else if interval < 3600 { + let minutes = Int(interval / 60) + return "\(minutes)m ago" + } else if interval < 86400 { + let hours = Int(interval / 3600) + return "\(hours)h ago" + } else { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter.string(from: date) + } + } +} + +// MARK: - Preview +#Preview("Connection Status Bar") { + VStack { + ConnectionStatusBar() + + Spacer() + } +} + +#Preview("Compact Status") { + HStack { + CompactConnectionStatus() + Spacer() + } + .padding() +} + +#Preview("Offline Banner") { + VStack { + OfflineModeBanner() + Spacer() + } +} diff --git a/ios-app/SlurmManager/SlurmManager/Components/JobRowView.swift b/ios-app/SlurmManager/SlurmManager/Components/JobRowView.swift index 9e00607..0a3cd10 100644 --- a/ios-app/SlurmManager/SlurmManager/Components/JobRowView.swift +++ b/ios-app/SlurmManager/SlurmManager/Components/JobRowView.swift @@ -1,19 +1,74 @@ import SwiftUI +import Combine +// MARK: - Enhanced Job Row View with Swipe Actions struct EnhancedJobRowView: View { let job: Job + var onCancel: (() -> Void)? + var onRefresh: (() -> Void)? + var onViewOutput: (() -> Void)? + @State private var isPressed = false @State private var showDetails = false - + @State private var offset: CGFloat = 0 + @State private var lastUpdate = Date() + + // Swipe action thresholds + private let swipeThreshold: CGFloat = 80 + private let maxSwipe: CGFloat = 160 + var body: some View { + ZStack { + // Swipe action backgrounds + HStack(spacing: 0) { + // Left swipe actions (trailing) + if job.isRunning { + swipeActionButton( + icon: "xmark.circle.fill", + label: "Cancel", + color: .red + ) { + onCancel?() + } + } + + Spacer() + + // Right swipe actions (leading) + swipeActionButton( + icon: "arrow.clockwise.circle.fill", + label: "Refresh", + color: .blue + ) { + onRefresh?() + } + + swipeActionButton( + icon: "doc.text.fill", + label: "Output", + color: .purple + ) { + onViewOutput?() + } + } + + // Main card content + mainContent + .offset(x: offset) + .gesture(swipeGesture) + } + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + // MARK: - Main Content + var mainContent: some View { CardView(padding: 0) { VStack(spacing: 0) { - // Main content HStack(spacing: 12) { // Status indicator with animation StatusIndicator(state: job.state) .frame(width: 4) - + // Job info VStack(alignment: .leading, spacing: 6) { // Title row @@ -22,21 +77,24 @@ struct EnhancedJobRowView: View { .font(.headline) .lineLimit(1) .foregroundColor(.primary) - + if job.cached { - Image(systemName: "clock.arrow.circlepath") - .font(.caption2) - .foregroundColor(.secondary) + CachedBadge() } - + Spacer() - - // Time display - if job.isRunning, let duration = job.formattedDuration { - TimeDisplay(duration: duration) + + // Live time display for running jobs + if job.isRunning { + LiveTimeDisplay(startTime: job.startTime) + } else if let duration = job.formattedDuration { + Text(duration) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.secondary) } } - + // Job ID and Host HStack(spacing: 12) { Label { @@ -47,7 +105,7 @@ struct EnhancedJobRowView: View { .font(.caption2) } .foregroundColor(.secondary) - + if let host = job.host.split(separator: ".").first { Label { Text(String(host)) @@ -58,8 +116,20 @@ struct EnhancedJobRowView: View { } .foregroundColor(.secondary) } + + Spacer() + + // User + Label { + Text(job.user) + .font(.caption) + } icon: { + Image(systemName: "person.circle") + .font(.caption2) + } + .foregroundColor(.secondary) } - + // Resource badges ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { @@ -70,15 +140,15 @@ struct EnhancedJobRowView: View { color: .blue ) } - + if let nodes = job.nodes { ResourceBadge( icon: "cpu", - text: nodes, + text: "\(nodes) nodes", color: .purple ) } - + if let memory = job.memory { ResourceBadge( icon: "memorychip", @@ -86,89 +156,173 @@ struct EnhancedJobRowView: View { color: .green ) } - + if let cpus = job.cpus { ResourceBadge( - icon: "speedometer", + icon: "bolt.fill", text: "\(cpus) CPUs", color: .orange ) } + + if let timeLimit = job.timeLimit { + ResourceBadge( + icon: "clock", + text: timeLimit, + color: .gray + ) + } } } } .padding(.vertical, 12) - - // State badge - VStack { - StatusBadge( - status: job.state.displayName, - color: statusColor - ) - + + // State badge and actions + VStack(spacing: 6) { + AnimatedStatusBadge(state: job.state) + if job.state == .running { ProgressIndicator() - .frame(width: 20, height: 20) - .padding(.top, 4) + .frame(width: 18, height: 18) } } .padding(.trailing, 12) } - - // Expandable details (if tapped) + + // Expandable quick info if showDetails { - Divider() - - VStack(alignment: .leading, spacing: 8) { - if let submitTime = job.submitTime { - DetailRow( - label: "Submitted", - value: formatDate(submitTime) - ) - } - - if let workDir = job.workDir { - DetailRow( - label: "Work Dir", - value: workDir - ) - .font(.caption2) - } - } - .padding(12) - .transition(.asymmetric( - insertion: .push(from: .top).combined(with: .opacity), - removal: .push(from: .bottom).combined(with: .opacity) - )) + expandedDetails } } } .scaleEffect(isPressed ? 0.98 : 1.0) - .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isPressed) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isPressed) + .contentShape(Rectangle()) .onTapGesture { - withAnimation(.spring()) { + withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) { showDetails.toggle() } HapticManager.impact(.light) } - .onLongPressGesture(minimumDuration: 0, maximumDistance: .infinity) { _ in - - } onPressingChanged: { pressing in - isPressed = pressing + .simultaneousGesture( + LongPressGesture(minimumDuration: 0.1) + .onChanged { _ in isPressed = true } + .onEnded { _ in isPressed = false } + ) + } + + // MARK: - Expanded Details + var expandedDetails: some View { + VStack(spacing: 0) { + Divider() + .padding(.horizontal, 12) + + VStack(alignment: .leading, spacing: 8) { + if let submitTime = job.submitTime { + DetailRow( + icon: "calendar.badge.clock", + label: "Submitted", + value: formatDate(submitTime) + ) + } + + if let startTime = job.startTime { + DetailRow( + icon: "play.circle.fill", + label: "Started", + value: formatDate(startTime) + ) + } + + if let endTime = job.endTime { + DetailRow( + icon: "stop.circle.fill", + label: "Ended", + value: formatDate(endTime) + ) + } + + if let workDir = job.workDir { + DetailRow( + icon: "folder.fill", + label: "Directory", + value: workDir + ) + .lineLimit(1) + } + + if let qos = job.qos { + DetailRow( + icon: "gauge.medium", + label: "QoS", + value: qos + ) + } + } + .padding(12) + .transition(.asymmetric( + insertion: .push(from: .top).combined(with: .opacity), + removal: .scale.combined(with: .opacity) + )) } } - - var statusColor: Color { - switch job.state { - case .running: return .blue - case .pending: return .orange - case .completed: return .green - case .failed, .timeout, .nodeFailure, .outOfMemory: return .red - case .cancelled: return .gray - default: return .gray + + // MARK: - Swipe Actions + var swipeGesture: some Gesture { + DragGesture(minimumDistance: 20, coordinateSpace: .local) + .onChanged { value in + let translation = value.translation.width + + // Limit swipe distance with rubber band effect + if translation > 0 { + offset = min(maxSwipe, translation * 0.6) + } else if job.isRunning { + offset = max(-maxSwipe, translation * 0.6) + } + } + .onEnded { value in + let velocity = value.predictedEndLocation.x - value.location.x + + withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) { + // Trigger action if past threshold or high velocity + if offset > swipeThreshold || velocity > 300 { + // Right swipe - refresh + HapticManager.notification(.success) + onRefresh?() + } else if offset < -swipeThreshold || velocity < -300 { + // Left swipe - cancel (if running) + if job.isRunning { + HapticManager.notification(.warning) + onCancel?() + } + } + + offset = 0 + } + } + } + + @ViewBuilder + func swipeActionButton( + icon: String, + label: String, + color: Color, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + VStack(spacing: 4) { + Image(systemName: icon) + .font(.title2) + Text(label) + .font(.caption2) + } + .foregroundColor(.white) + .frame(width: 80) + .frame(maxHeight: .infinity) + .background(color) } } - + private func formatDate(_ date: Date) -> String { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .abbreviated @@ -176,134 +330,218 @@ struct EnhancedJobRowView: View { } } -// MARK: - Supporting Components - -struct StatusIndicator: View { +// MARK: - Animated Status Badge +struct AnimatedStatusBadge: View { let state: JobState @State private var isAnimating = false - + var body: some View { - Rectangle() - .fill(color) - .overlay( - Group { + Text(state.displayName) + .font(.caption) + .fontWeight(.semibold) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + ZStack { + // Base color + color.opacity(0.15) + + // Animated glow for running if state == .running { - Rectangle() - .fill( - LinearGradient( - colors: [color.opacity(0), color, color.opacity(0)], - startPoint: .top, - endPoint: .bottom - ) - ) - .offset(y: isAnimating ? 30 : -30) + color.opacity(isAnimating ? 0.3 : 0.1) .animation( - Animation.linear(duration: 1.5) - .repeatForever(autoreverses: false), + .easeInOut(duration: 1.5) + .repeatForever(autoreverses: true), value: isAnimating ) } } ) - .mask(Rectangle()) + .foregroundColor(color) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(color.opacity(0.3), lineWidth: 1) + ) .onAppear { if state == .running { isAnimating = true } } } - - var color: Color { - switch state { - case .running: return .blue - case .pending: return .orange - case .completed: return .green - case .failed, .timeout, .nodeFailure, .outOfMemory: return .red - case .cancelled: return .gray - default: return .gray - } - } -} -struct ResourceBadge: View { - let icon: String - let text: String - let color: Color - - var body: some View { - HStack(spacing: 4) { - Image(systemName: icon) - .font(.caption2) - Text(text) - .font(.caption2) - .fontWeight(.medium) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(color.opacity(0.15)) - .foregroundColor(color) - .cornerRadius(6) + var color: Color { + state.color } } -struct TimeDisplay: View { - let duration: String +// MARK: - Live Time Display +struct LiveTimeDisplay: View { + let startTime: Date? + @State private var currentTime = Date() @State private var pulse = false - + + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + var body: some View { HStack(spacing: 4) { Circle() .fill(Color.green) .frame(width: 6, height: 6) - .scaleEffect(pulse ? 1.2 : 0.8) + .scaleEffect(pulse ? 1.3 : 0.8) .animation( - Animation.easeInOut(duration: 1) - .repeatForever(autoreverses: true), + .easeInOut(duration: 1) + .repeatForever(autoreverses: true), value: pulse ) - - Text(duration) - .font(.caption) + + Text(formattedDuration) + .font(.system(.caption, design: .monospaced)) .fontWeight(.medium) .foregroundColor(.blue) } + .onReceive(timer) { time in + currentTime = time + } .onAppear { pulse = true } } + + var formattedDuration: String { + guard let start = startTime else { return "--:--" } + let duration = currentTime.timeIntervalSince(start) + + let hours = Int(duration) / 3600 + let minutes = (Int(duration) % 3600) / 60 + let seconds = Int(duration) % 60 + + if hours > 0 { + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } else { + return String(format: "%02d:%02d", minutes, seconds) + } + } +} + +// MARK: - Cached Badge +struct CachedBadge: View { + var body: some View { + HStack(spacing: 2) { + Image(systemName: "clock.arrow.circlepath") + Text("cached") + } + .font(.system(size: 9)) + .foregroundColor(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Color(.systemGray6)) + .cornerRadius(4) + } +} + +// MARK: - Supporting Components +struct StatusIndicator: View { + let state: JobState + @State private var isAnimating = false + + var body: some View { + GeometryReader { geometry in + Rectangle() + .fill(color) + .overlay( + Group { + if state == .running { + Rectangle() + .fill( + LinearGradient( + colors: [color.opacity(0), color.opacity(0.8), color.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(height: geometry.size.height * 0.5) + .offset(y: isAnimating ? geometry.size.height : -geometry.size.height) + .animation( + .linear(duration: 1.5) + .repeatForever(autoreverses: false), + value: isAnimating + ) + } + } + ) + .mask(Rectangle()) + } + .onAppear { + if state == .running { + isAnimating = true + } + } + } + + var color: Color { + state.color + } +} + +struct ResourceBadge: View { + let icon: String + let text: String + let color: Color + + var body: some View { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 9)) + Text(text) + .font(.system(size: 10, weight: .medium)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(color.opacity(0.12)) + .foregroundColor(color) + .cornerRadius(6) + } } struct ProgressIndicator: View { @State private var rotation = 0.0 - + var body: some View { Circle() .trim(from: 0.0, to: 0.7) .stroke( AngularGradient( - colors: [.blue, .blue.opacity(0.5), .blue.opacity(0)], + colors: [.blue, .blue.opacity(0.3), .blue.opacity(0)], center: .center ), - lineWidth: 2 + style: StrokeStyle(lineWidth: 2, lineCap: .round) ) .rotationEffect(.degrees(rotation)) - .animation( - Animation.linear(duration: 1) - .repeatForever(autoreverses: false), - value: rotation - ) .onAppear { - rotation = 360 + withAnimation( + .linear(duration: 1) + .repeatForever(autoreverses: false) + ) { + rotation = 360 + } } } } struct DetailRow: View { + var icon: String = "" let label: String let value: String - + var body: some View { - HStack { + HStack(spacing: 8) { + if !icon.isEmpty { + Image(systemName: icon) + .font(.caption2) + .foregroundColor(.secondary) + .frame(width: 14) + } Text(label) .font(.caption) .foregroundColor(.secondary) @@ -311,27 +549,132 @@ struct DetailRow: View { Text(value) .font(.caption) .foregroundColor(.primary) + .lineLimit(1) } } } struct ConnectionStatusBadge: View { - @EnvironmentObject var appState: AppState - + @StateObject private var appState = AppState.shared + var body: some View { - HStack(spacing: 4) { + HStack(spacing: 6) { Circle() - .fill(appState.connectionStatus.color) + .fill(statusColor) .frame(width: 8, height: 8) - - if appState.connectionStatus == .connecting { + .shadow(color: statusColor.opacity(0.5), radius: 2) + + if case .connecting = appState.connectionStatus { ProgressView() .scaleEffect(0.5) + .frame(width: 12, height: 12) } } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(appState.connectionStatus.color.opacity(0.15)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(statusColor.opacity(0.12)) .cornerRadius(12) + .animation(.easeInOut(duration: 0.3), value: appState.connectionStatus.displayText) } -} \ No newline at end of file + + var statusColor: Color { + appState.connectionStatus.color + } +} + +// MARK: - JobState Color Extension +extension JobState { + var color: Color { + switch self { + case .running: return .blue + case .pending: return .orange + case .completed: return .green + case .failed, .timeout, .nodeFailure, .outOfMemory, .bootFail: return .red + case .cancelled: return .gray + case .completing, .configuring: return .cyan + case .suspended, .preempted: return .yellow + case .deadline: return .pink + } + } +} + +// MARK: - Preview +#Preview { + VStack(spacing: 12) { + EnhancedJobRowView( + job: Job( + id: "12345", + name: "training_model_v2", + user: "jsmith", + state: .running, + submitTime: Date().addingTimeInterval(-3600), + startTime: Date().addingTimeInterval(-1800), + endTime: nil, + partition: "gpu", + nodes: "4", + cpus: 32, + memory: "128G", + timeLimit: "24:00:00", + workDir: "/home/jsmith/projects/ml-training", + command: nil, + array: nil, + qos: "normal", + account: "research", + host: "cluster1.example.com", + cached: false + ), + onCancel: { print("Cancel") }, + onRefresh: { print("Refresh") }, + onViewOutput: { print("View Output") } + ) + + EnhancedJobRowView( + job: Job( + id: "12346", + name: "data_preprocessing", + user: "jsmith", + state: .completed, + submitTime: Date().addingTimeInterval(-7200), + startTime: Date().addingTimeInterval(-6000), + endTime: Date().addingTimeInterval(-3600), + partition: "batch", + nodes: "1", + cpus: 8, + memory: "16G", + timeLimit: "02:00:00", + workDir: nil, + command: nil, + array: nil, + qos: nil, + account: nil, + host: "cluster2.example.com", + cached: true + ) + ) + + EnhancedJobRowView( + job: Job( + id: "12347", + name: "waiting_job", + user: "jsmith", + state: .pending, + submitTime: Date().addingTimeInterval(-300), + startTime: nil, + endTime: nil, + partition: "gpu", + nodes: "8", + cpus: 64, + memory: "256G", + timeLimit: "48:00:00", + workDir: nil, + command: nil, + array: nil, + qos: "high", + account: nil, + host: "cluster1.example.com", + cached: false + ) + ) + } + .padding() +} diff --git a/ios-app/SlurmManager/SlurmManager/Components/SkeletonViews.swift b/ios-app/SlurmManager/SlurmManager/Components/SkeletonViews.swift new file mode 100644 index 0000000..7709b6c --- /dev/null +++ b/ios-app/SlurmManager/SlurmManager/Components/SkeletonViews.swift @@ -0,0 +1,336 @@ +import SwiftUI + +// MARK: - Skeleton Loading Views +/// App Store quality skeleton views for smooth loading states + +// MARK: - Shimmer Effect +struct ShimmerEffect: ViewModifier { + @State private var phase: CGFloat = 0 + + func body(content: Content) -> some View { + content + .overlay( + GeometryReader { geometry in + LinearGradient( + gradient: Gradient(colors: [ + Color.white.opacity(0), + Color.white.opacity(0.4), + Color.white.opacity(0) + ]), + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: geometry.size.width * 2) + .offset(x: -geometry.size.width + (phase * geometry.size.width * 3)) + } + ) + .mask(content) + .onAppear { + withAnimation( + .linear(duration: 1.5) + .repeatForever(autoreverses: false) + ) { + phase = 1 + } + } + } +} + +extension View { + func skeletonShimmer() -> some View { + modifier(ShimmerEffect()) + } +} + +// MARK: - Skeleton Shape +struct SkeletonShape: View { + var width: CGFloat? = nil + var height: CGFloat = 16 + var cornerRadius: CGFloat = 4 + + var body: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Color(.systemGray5)) + .frame(width: width, height: height) + .skeletonShimmer() + } +} + +// MARK: - Job Row Skeleton +struct JobRowSkeleton: View { + var body: some View { + HStack(spacing: 12) { + // Status indicator + Circle() + .fill(Color(.systemGray5)) + .frame(width: 10, height: 10) + .skeletonShimmer() + + VStack(alignment: .leading, spacing: 8) { + // Job name + SkeletonShape(width: 180, height: 18) + + // Job ID and host + HStack(spacing: 12) { + SkeletonShape(width: 80, height: 12) + SkeletonShape(width: 60, height: 12) + } + + // Duration and partition + HStack(spacing: 12) { + SkeletonShape(width: 50, height: 12) + SkeletonShape(width: 70, height: 12) + } + } + + Spacer() + + // State badge + SkeletonShape(width: 70, height: 24, cornerRadius: 6) + } + .padding(.vertical, 8) + .padding(.horizontal, 4) + } +} + +// MARK: - Job List Skeleton +struct JobListSkeleton: View { + var count: Int = 8 + + var body: some View { + List { + ForEach(0.. some View { + if isLoading { + content + .redacted(reason: .placeholder) + .skeletonShimmer() + } else { + content + } + } +} + +extension View { + func skeleton(isLoading: Bool) -> some View { + modifier(RedactedSkeleton(isLoading: isLoading)) + } +} + +// MARK: - Animated Skeleton List +struct AnimatedSkeletonList: View { + let isLoading: Bool + let skeletonCount: Int + @ViewBuilder let content: () -> Content + @ViewBuilder let skeleton: () -> some View + + @State private var appeared = false + + var body: some View { + Group { + if isLoading && !appeared { + List { + ForEach(0.. some View { + content + .scaleEffect(isPulsing ? 1.05 : 1.0) + .opacity(isPulsing ? 0.8 : 1.0) + .animation( + .easeInOut(duration: 1.0).repeatForever(autoreverses: true), + value: isPulsing + ) + .onAppear { + isPulsing = true + } + } +} + +extension View { + func pulseAnimation() -> some View { + modifier(PulseAnimation()) + } +} + +// MARK: - Preview +#Preview("Job Row Skeleton") { + List { + JobRowSkeleton() + JobRowSkeleton() + JobRowSkeleton() + } + .listStyle(.plain) +} + +#Preview("Job Detail Skeleton") { + JobDetailSkeleton() +} + +#Preview("Host Card Skeleton") { + VStack { + HostCardSkeleton() + HostCardSkeleton() + } + .padding() +} diff --git a/ios-app/SlurmManager/SlurmManager/Models/Job.swift b/ios-app/SlurmManager/SlurmManager/Models/Job.swift index 1a0aa2a..8347897 100644 --- a/ios-app/SlurmManager/SlurmManager/Models/Job.swift +++ b/ios-app/SlurmManager/SlurmManager/Models/Job.swift @@ -22,7 +22,15 @@ struct Job: Codable, Identifiable, Hashable { let account: String? let host: String let cached: Bool - + + // Additional fields from backend + let runtime: String? + let gpus: Int? + let exitCode: Int? + let reason: String? + let arrayJobId: String? + let arrayTaskId: String? + enum CodingKeys: String, CodingKey { case id = "job_id" case name @@ -41,22 +49,157 @@ struct Job: Codable, Identifiable, Hashable { case array case qos case account - case host + case host = "hostname" case cached + case runtime + case gpus = "gpus_per_node" + case exitCode = "exit_code" + case reason + case arrayJobId = "array_job_id" + case arrayTaskId = "array_task_id" + } + + // Custom decoder to handle missing fields and flexible state parsing + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(String.self, forKey: .id) + name = try container.decodeIfPresent(String.self, forKey: .name) ?? "Unknown" + user = try container.decodeIfPresent(String.self, forKey: .user) ?? "Unknown" + + // Flexible state decoding + if let stateStr = try container.decodeIfPresent(String.self, forKey: .state) { + state = JobState(rawValue: stateStr) ?? .unknown + } else { + state = .unknown + } + + // Date parsing with multiple formats + submitTime = try Self.decodeDate(container: container, key: .submitTime) + startTime = try Self.decodeDate(container: container, key: .startTime) + endTime = try Self.decodeDate(container: container, key: .endTime) + + partition = try container.decodeIfPresent(String.self, forKey: .partition) + nodes = try container.decodeIfPresent(String.self, forKey: .nodes) + cpus = try container.decodeIfPresent(Int.self, forKey: .cpus) + memory = try container.decodeIfPresent(String.self, forKey: .memory) + timeLimit = try container.decodeIfPresent(String.self, forKey: .timeLimit) + workDir = try container.decodeIfPresent(String.self, forKey: .workDir) + command = try container.decodeIfPresent(String.self, forKey: .command) + array = try container.decodeIfPresent(String.self, forKey: .array) + qos = try container.decodeIfPresent(String.self, forKey: .qos) + account = try container.decodeIfPresent(String.self, forKey: .account) + host = try container.decodeIfPresent(String.self, forKey: .host) ?? "unknown" + cached = try container.decodeIfPresent(Bool.self, forKey: .cached) ?? false + runtime = try container.decodeIfPresent(String.self, forKey: .runtime) + gpus = try container.decodeIfPresent(Int.self, forKey: .gpus) + exitCode = try container.decodeIfPresent(Int.self, forKey: .exitCode) + reason = try container.decodeIfPresent(String.self, forKey: .reason) + arrayJobId = try container.decodeIfPresent(String.self, forKey: .arrayJobId) + arrayTaskId = try container.decodeIfPresent(String.self, forKey: .arrayTaskId) + } + + private static func decodeDate(container: KeyedDecodingContainer, key: CodingKeys) throws -> Date? { + if let dateStr = try container.decodeIfPresent(String.self, forKey: key) { + // Try ISO 8601 format first + let iso8601 = ISO8601DateFormatter() + iso8601.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = iso8601.date(from: dateStr) { + return date + } + + // Try without fractional seconds + iso8601.formatOptions = [.withInternetDateTime] + if let date = iso8601.date(from: dateStr) { + return date + } + + // Try custom format + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + if let date = formatter.date(from: dateStr) { + return date + } + } + return nil } - + + // Manual initializer for previews and testing + init( + id: String, + name: String, + user: String, + state: JobState, + submitTime: Date?, + startTime: Date?, + endTime: Date?, + partition: String?, + nodes: String?, + cpus: Int?, + memory: String?, + timeLimit: String?, + workDir: String?, + command: String?, + array: String?, + qos: String?, + account: String?, + host: String, + cached: Bool, + runtime: String? = nil, + gpus: Int? = nil, + exitCode: Int? = nil, + reason: String? = nil, + arrayJobId: String? = nil, + arrayTaskId: String? = nil + ) { + self.id = id + self.name = name + self.user = user + self.state = state + self.submitTime = submitTime + self.startTime = startTime + self.endTime = endTime + self.partition = partition + self.nodes = nodes + self.cpus = cpus + self.memory = memory + self.timeLimit = timeLimit + self.workDir = workDir + self.command = command + self.array = array + self.qos = qos + self.account = account + self.host = host + self.cached = cached + self.runtime = runtime + self.gpus = gpus + self.exitCode = exitCode + self.reason = reason + self.arrayJobId = arrayJobId + self.arrayTaskId = arrayTaskId + } + var isRunning: Bool { state == .running } - + var isPending: Bool { state == .pending } - + var isCompleted: Bool { state == .completed || state == .failed || state == .cancelled } - + + var isTerminal: Bool { + switch state { + case .completed, .failed, .cancelled, .timeout, .nodeFailure, .outOfMemory, .bootFail, .deadline: + return true + default: + return false + } + } + var statusColor: String { switch state { case .running: return "blue" @@ -67,19 +210,25 @@ struct Job: Codable, Identifiable, Hashable { default: return "gray" } } - + var formattedDuration: String? { + // First try runtime from server + if let runtime = runtime, !runtime.isEmpty { + return runtime + } + + // Otherwise calculate guard let start = startTime else { return nil } let end = endTime ?? Date() let duration = end.timeIntervalSince(start) return formatDuration(duration) } - + private func formatDuration(_ duration: TimeInterval) -> String { let hours = Int(duration) / 3600 let minutes = (Int(duration) % 3600) / 60 let seconds = Int(duration) % 60 - + if hours > 0 { return String(format: "%02d:%02d:%02d", hours, minutes, seconds) } else { @@ -103,7 +252,29 @@ enum JobState: String, Codable, CaseIterable { case bootFail = "BOOT_FAIL" case deadline = "DEADLINE" case outOfMemory = "OUT_OF_MEMORY" - + case unknown = "UNKNOWN" + + // Handle short state codes from SLURM + init(rawValue: String) { + switch rawValue.uppercased() { + case "PD", "PENDING": self = .pending + case "R", "RUNNING": self = .running + case "CD", "COMPLETED": self = .completed + case "F", "FAILED": self = .failed + case "CA", "CANCELLED": self = .cancelled + case "CG", "COMPLETING": self = .completing + case "CF", "CONFIGURING": self = .configuring + case "S", "SUSPENDED": self = .suspended + case "TO", "TIMEOUT": self = .timeout + case "NF", "NODE_FAIL": self = .nodeFailure + case "PR", "PREEMPTED": self = .preempted + case "BF", "BOOT_FAIL": self = .bootFail + case "DL", "DEADLINE": self = .deadline + case "OOM", "OUT_OF_MEMORY": self = .outOfMemory + default: self = .unknown + } + } + var displayName: String { switch self { case .pending: return "Pending" @@ -120,6 +291,7 @@ enum JobState: String, Codable, CaseIterable { case .bootFail: return "Boot Fail" case .deadline: return "Deadline" case .outOfMemory: return "Out of Memory" + case .unknown: return "Unknown" } } } @@ -127,20 +299,38 @@ enum JobState: String, Codable, CaseIterable { // MARK: - Job Detail struct JobDetail: Codable { - let job: Job + let job: Job? let script: String? let output: String? let error: String? let outputPath: String? let errorPath: String? - + let cached: Bool? + + enum CodingKeys: String, CodingKey { + case job = "job_info" + case script = "script_content" + case output = "stdout" + case error = "stderr" + case outputPath = "stdout_metadata" + case errorPath = "stderr_metadata" + case cached = "cached_at" + } +} + +// MARK: - Job Output Response + +struct JobOutputResponse: Codable { + let output: String? + let error: String? + let jobId: String + let host: String + enum CodingKeys: String, CodingKey { - case job - case script - case output - case error - case outputPath = "output_path" - case errorPath = "error_path" + case output = "stdout" + case error = "stderr" + case jobId = "job_id" + case host = "hostname" } } @@ -148,38 +338,62 @@ struct JobDetail: Codable { struct JobStatusResponse: Codable { let jobs: [Job] - let total: Int - let page: Int - let pageSize: Int + let total: Int? + let page: Int? + let pageSize: Int? let hosts: [String] - let fromCache: Bool - let partialResults: Bool + let fromCache: Bool? + let partialResults: Bool? let errors: [String: String]? - + let queryTime: Double? + enum CodingKeys: String, CodingKey { case jobs - case total + case total = "total_jobs" case page case pageSize = "page_size" case hosts - case fromCache = "from_cache" + case fromCache = "cached" case partialResults = "partial_results" case errors + case queryTime = "query_time" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Handle nested jobs array or direct array + if let jobsArray = try? container.decode([Job].self, forKey: .jobs) { + jobs = jobsArray + } else { + jobs = [] + } + + total = try container.decodeIfPresent(Int.self, forKey: .total) + page = try container.decodeIfPresent(Int.self, forKey: .page) + pageSize = try container.decodeIfPresent(Int.self, forKey: .pageSize) + hosts = try container.decodeIfPresent([String].self, forKey: .hosts) ?? [] + fromCache = try container.decodeIfPresent(Bool.self, forKey: .fromCache) + partialResults = try container.decodeIfPresent(Bool.self, forKey: .partialResults) + errors = try container.decodeIfPresent([String: String].self, forKey: .errors) + queryTime = try container.decodeIfPresent(Double.self, forKey: .queryTime) } } // MARK: - Launch Job Models struct LaunchJobRequest: Codable { - let scriptPath: String - let sourceDir: String + let scriptPath: String? + let scriptContent: String? + let sourceDir: String? let host: String - let jobName: String? + let jobName: String let slurmParams: SlurmParameters? let syncSettings: SyncSettings? - + enum CodingKeys: String, CodingKey { case scriptPath = "script_path" + case scriptContent = "script_content" case sourceDir = "source_dir" case host case jobName = "job_name" @@ -189,18 +403,20 @@ struct LaunchJobRequest: Codable { } struct LaunchJobResponse: Codable { + let success: Bool let jobId: String - let host: String - let jobName: String - let submitDir: String - let syncResult: SyncResult? - + let message: String? + let host: String? + let directoryWarning: String? + let requiresConfirmation: Bool? + enum CodingKeys: String, CodingKey { + case success case jobId = "job_id" - case host - case jobName = "job_name" - case submitDir = "submit_dir" - case syncResult = "sync_result" + case message + case host = "hostname" + case directoryWarning = "directory_warning" + case requiresConfirmation = "requires_confirmation" } } @@ -215,28 +431,56 @@ struct SlurmParameters: Codable { let qos: String? let array: String? let exclusive: Bool? + + enum CodingKeys: String, CodingKey { + case partition + case nodes + case cpus + case memory = "mem" + case time + case gpus = "gpus_per_node" + case account + case qos + case array + case exclusive + } } struct SyncSettings: Codable { let excludePatterns: [String]? let includePatterns: [String]? let dryRun: Bool? - + enum CodingKeys: String, CodingKey { - case excludePatterns = "exclude_patterns" - case includePatterns = "include_patterns" - case dryRun = "dry_run" + case excludePatterns = "exclude" + case includePatterns = "include" + case dryRun = "no_gitignore" } } -struct SyncResult: Codable { - let filesTransferred: Int - let bytesTransferred: Int - let duration: Double - +// MARK: - Array Job Group + +struct ArrayJobGroup: Codable, Identifiable { + var id: String { arrayJobId } + let arrayJobId: String + let jobName: String + let hostname: String + let totalTasks: Int + let stateCounts: [String: Int] + enum CodingKeys: String, CodingKey { - case filesTransferred = "files_transferred" - case bytesTransferred = "bytes_transferred" - case duration + case arrayJobId = "array_job_id" + case jobName = "job_name" + case hostname + case totalTasks = "total_tasks" + case stateCounts = "state_counts" } -} \ No newline at end of file +} + +// MARK: - Output Type + +enum OutputType: String { + case stdout + case stderr + case both +} diff --git a/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..a12a767 --- /dev/null +++ b/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.976", + "green" : "0.439", + "red" : "0.345" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.584", + "red" : "0.494" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f19609f --- /dev/null +++ b/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,74 @@ +{ + "images" : [ + { + "filename" : "AppIcon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "filename" : "AppIcon-16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "AppIcon-32.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "AppIcon-32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "AppIcon-64.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "AppIcon-128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "AppIcon-256.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "AppIcon-256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "AppIcon-512.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "AppIcon-512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "AppIcon-1024.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/Contents.json b/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/JobCompleted.colorset/Contents.json b/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/JobCompleted.colorset/Contents.json new file mode 100644 index 0000000..861cb3a --- /dev/null +++ b/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/JobCompleted.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.349", + "green" : "0.780", + "red" : "0.204" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/JobFailed.colorset/Contents.json b/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/JobFailed.colorset/Contents.json new file mode 100644 index 0000000..7235ecf --- /dev/null +++ b/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/JobFailed.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.231", + "green" : "0.231", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/JobPending.colorset/Contents.json b/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/JobPending.colorset/Contents.json new file mode 100644 index 0000000..c36675a --- /dev/null +++ b/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/JobPending.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.584", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/JobRunning.colorset/Contents.json b/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/JobRunning.colorset/Contents.json new file mode 100644 index 0000000..ec17a7b --- /dev/null +++ b/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/JobRunning.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.839", + "green" : "0.635", + "red" : "0.204" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/LaunchScreenBackground.colorset/Contents.json b/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/LaunchScreenBackground.colorset/Contents.json new file mode 100644 index 0000000..37b7ff2 --- /dev/null +++ b/ios-app/SlurmManager/SlurmManager/Resources/Assets.xcassets/LaunchScreenBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.118", + "green" : "0.082", + "red" : "0.063" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios-app/SlurmManager/SlurmManager/Services/CacheManager.swift b/ios-app/SlurmManager/SlurmManager/Services/CacheManager.swift new file mode 100644 index 0000000..e35ab84 --- /dev/null +++ b/ios-app/SlurmManager/SlurmManager/Services/CacheManager.swift @@ -0,0 +1,393 @@ +import Foundation +import Combine + +// MARK: - Persistent Cache Manager +/// High-performance caching layer with disk persistence for offline support and fast load times +actor CacheManager { + static let shared = CacheManager() + + // MARK: - Configuration + private let cacheDirectory: URL + private let jobCacheFile: URL + private let hostCacheFile: URL + private let metadataCacheFile: URL + + // Cache expiration times (in seconds) + private let activeJobExpiration: TimeInterval = 60 // 1 minute for active jobs + private let completedJobExpiration: TimeInterval = 3600 // 1 hour for completed jobs + private let hostExpiration: TimeInterval = 300 // 5 minutes for hosts + + // In-memory cache for immediate access + private var jobCache: [String: CachedJob] = [:] + private var hostCache: [String: CachedHost] = [:] + private var lastSyncTime: Date? + + // MARK: - Cache Entry Types + struct CachedJob: Codable { + let job: Job + let cachedAt: Date + let expiresAt: Date + + var isExpired: Bool { + Date() > expiresAt + } + + var isStale: Bool { + // Stale after half the expiration time + Date() > cachedAt.addingTimeInterval((expiresAt.timeIntervalSince(cachedAt)) / 2) + } + } + + struct CachedHost: Codable { + let host: Host + let cachedAt: Date + let expiresAt: Date + + var isExpired: Bool { + Date() > expiresAt + } + } + + struct CacheMetadata: Codable { + var lastFullSync: Date? + var totalJobsCached: Int + var cacheVersion: Int + var appVersion: String + + static let currentVersion = 1 + } + + // MARK: - Initialization + private init() { + let fileManager = FileManager.default + let cachePath = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! + cacheDirectory = cachePath.appendingPathComponent("SlurmManager", isDirectory: true) + + jobCacheFile = cacheDirectory.appendingPathComponent("jobs.json") + hostCacheFile = cacheDirectory.appendingPathComponent("hosts.json") + metadataCacheFile = cacheDirectory.appendingPathComponent("metadata.json") + + // Create cache directory if needed + try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) + + // Load persisted cache on init + Task { + await loadPersistedCache() + } + } + + // MARK: - Job Cache Operations + + /// Cache a job with automatic expiration based on state + func cacheJob(_ job: Job) { + let expiration = job.state.isTerminal ? completedJobExpiration : activeJobExpiration + let cached = CachedJob( + job: job, + cachedAt: Date(), + expiresAt: Date().addingTimeInterval(expiration) + ) + jobCache[job.id] = cached + + // Debounced persist + schedulePersist() + } + + /// Cache multiple jobs efficiently + func cacheJobs(_ jobs: [Job]) { + for job in jobs { + let expiration = job.state.isTerminal ? completedJobExpiration : activeJobExpiration + let cached = CachedJob( + job: job, + cachedAt: Date(), + expiresAt: Date().addingTimeInterval(expiration) + ) + jobCache[job.id] = cached + } + + // Single persist after batch + schedulePersist() + } + + /// Get a cached job if not expired + func getJob(id: String) -> Job? { + guard let cached = jobCache[id], !cached.isExpired else { + return nil + } + return cached.job + } + + /// Get a cached job even if stale (for immediate display while refreshing) + func getJobStaleAllowed(id: String) -> (job: Job, isStale: Bool)? { + guard let cached = jobCache[id] else { + return nil + } + + if cached.isExpired { + return nil + } + + return (cached.job, cached.isStale) + } + + /// Get all cached jobs, optionally including stale ones + func getAllJobs(includeStale: Bool = false) -> [Job] { + let now = Date() + return jobCache.values + .filter { includeStale ? $0.expiresAt > now : !$0.isStale } + .map { $0.job } + .sorted { ($0.submitTime ?? .distantPast) > ($1.submitTime ?? .distantPast) } + } + + /// Get jobs by state + func getJobs(byState state: JobState) -> [Job] { + return jobCache.values + .filter { !$0.isExpired && $0.job.state == state } + .map { $0.job } + } + + /// Get jobs by host + func getJobs(byHost host: String) -> [Job] { + return jobCache.values + .filter { !$0.isExpired && $0.job.host == host } + .map { $0.job } + } + + /// Update a job in cache + func updateJob(_ job: Job) { + if jobCache[job.id] != nil { + cacheJob(job) + } + } + + /// Remove a job from cache + func removeJob(id: String) { + jobCache.removeValue(forKey: id) + schedulePersist() + } + + // MARK: - Host Cache Operations + + func cacheHost(_ host: Host) { + let cached = CachedHost( + host: host, + cachedAt: Date(), + expiresAt: Date().addingTimeInterval(hostExpiration) + ) + hostCache[host.id] = cached + schedulePersist() + } + + func cacheHosts(_ hosts: [Host]) { + for host in hosts { + let cached = CachedHost( + host: host, + cachedAt: Date(), + expiresAt: Date().addingTimeInterval(hostExpiration) + ) + hostCache[host.id] = cached + } + schedulePersist() + } + + func getHost(id: String) -> Host? { + guard let cached = hostCache[id], !cached.isExpired else { + return nil + } + return cached.host + } + + func getAllHosts() -> [Host] { + return hostCache.values + .filter { !$0.isExpired } + .map { $0.host } + } + + // MARK: - Cache Metadata + + func getLastSyncTime() -> Date? { + return lastSyncTime + } + + func setLastSyncTime(_ date: Date) { + lastSyncTime = date + schedulePersist() + } + + func getCacheStats() -> (jobs: Int, hosts: Int, lastSync: Date?) { + let validJobs = jobCache.values.filter { !$0.isExpired }.count + let validHosts = hostCache.values.filter { !$0.isExpired }.count + return (validJobs, validHosts, lastSyncTime) + } + + // MARK: - Cache Maintenance + + /// Clear expired entries to free memory + func pruneExpired() { + let now = Date() + jobCache = jobCache.filter { !$0.value.isExpired } + hostCache = hostCache.filter { !$0.value.isExpired } + schedulePersist() + } + + /// Clear all cache + func clearAll() { + jobCache.removeAll() + hostCache.removeAll() + lastSyncTime = nil + + // Delete persisted files + try? FileManager.default.removeItem(at: jobCacheFile) + try? FileManager.default.removeItem(at: hostCacheFile) + try? FileManager.default.removeItem(at: metadataCacheFile) + } + + /// Clear only job cache + func clearJobs() { + jobCache.removeAll() + try? FileManager.default.removeItem(at: jobCacheFile) + } + + // MARK: - Persistence + + private var persistTask: Task? + + private func schedulePersist() { + persistTask?.cancel() + persistTask = Task { + try? await Task.sleep(nanoseconds: 500_000_000) // 500ms debounce + if !Task.isCancelled { + await persistCache() + } + } + } + + private func persistCache() { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + // Persist jobs + if let jobData = try? encoder.encode(Array(jobCache.values)) { + try? jobData.write(to: jobCacheFile) + } + + // Persist hosts + if let hostData = try? encoder.encode(Array(hostCache.values)) { + try? hostData.write(to: hostCacheFile) + } + + // Persist metadata + let metadata = CacheMetadata( + lastFullSync: lastSyncTime, + totalJobsCached: jobCache.count, + cacheVersion: CacheMetadata.currentVersion, + appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + ) + if let metaData = try? encoder.encode(metadata) { + try? metaData.write(to: metadataCacheFile) + } + } + + private func loadPersistedCache() { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + // Load metadata first to check version + if let metaData = try? Data(contentsOf: metadataCacheFile), + let metadata = try? decoder.decode(CacheMetadata.self, from: metaData) { + + // Check cache version compatibility + if metadata.cacheVersion != CacheMetadata.currentVersion { + // Cache version mismatch, clear all + clearAll() + return + } + + lastSyncTime = metadata.lastFullSync + } + + // Load jobs + if let jobData = try? Data(contentsOf: jobCacheFile), + let cachedJobs = try? decoder.decode([CachedJob].self, from: jobData) { + for cached in cachedJobs where !cached.isExpired { + jobCache[cached.job.id] = cached + } + } + + // Load hosts + if let hostData = try? Data(contentsOf: hostCacheFile), + let cachedHosts = try? decoder.decode([CachedHost].self, from: hostData) { + for cached in cachedHosts where !cached.isExpired { + hostCache[cached.host.id] = cached + } + } + } +} + +// MARK: - JobState Extension +extension JobState { + var isTerminal: Bool { + switch self { + case .completed, .failed, .cancelled, .timeout, .nodeFailure, .outOfMemory, .bootFail, .deadline: + return true + default: + return false + } + } +} + +// MARK: - Cache-Aware Job Manager Extension +extension JobManager { + /// Fetch jobs with cache-first strategy for instant display + @MainActor + func fetchJobsCached( + host: String? = nil, + forceRefresh: Bool = false + ) async throws -> [Job] { + // Immediately return cached data if available + if !forceRefresh { + let cachedJobs = await CacheManager.shared.getAllJobs(includeStale: true) + if !cachedJobs.isEmpty { + // Update UI immediately with cached data + self.jobs = cachedJobs + + // Check if we need to refresh + let stats = await CacheManager.shared.getCacheStats() + if let lastSync = stats.lastSync, + Date().timeIntervalSince(lastSync) < 30 { // Within 30 seconds + return cachedJobs + } + } + } + + // Fetch fresh data in background + let response = try await fetchJobsFromAPI(host: host) + + // Cache the results + await CacheManager.shared.cacheJobs(response.jobs) + await CacheManager.shared.setLastSyncTime(Date()) + + // Update main job list + self.jobs = response.jobs + + return response.jobs + } + + private func fetchJobsFromAPI(host: String?) async throws -> JobStatusResponse { + return try await withCheckedThrowingContinuation { continuation in + var cancellable: AnyCancellable? + cancellable = APIClient.shared.getJobs(host: host) + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + continuation.resume(throwing: error) + } + cancellable?.cancel() + }, + receiveValue: { response in + continuation.resume(returning: response) + cancellable?.cancel() + } + ) + } + } +} diff --git a/ios-app/SlurmManager/SlurmManager/SlurmManagerApp.swift b/ios-app/SlurmManager/SlurmManager/SlurmManagerApp.swift index c5ac6ac..25947db 100644 --- a/ios-app/SlurmManager/SlurmManager/SlurmManagerApp.swift +++ b/ios-app/SlurmManager/SlurmManager/SlurmManagerApp.swift @@ -2,32 +2,297 @@ import SwiftUI @main struct SlurmManagerApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var authManager = AuthenticationManager.shared - @StateObject private var apiClient = APIClient.shared - @StateObject private var jobManager = JobManager.shared - + @StateObject private var appState = AppState.shared + + @State private var showOnboarding = false + @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false + @AppStorage("preferredColorScheme") private var preferredColorScheme = 0 + var body: some Scene { WindowGroup { - ContentView() - .environmentObject(authManager) - .environmentObject(apiClient) - .environmentObject(jobManager) - .onAppear { - setupApp() + ZStack { + // Main content + if authManager.isAuthenticated { + MainTabView() + .environmentObject(authManager) + .environmentObject(appState) + .overlay(alignment: .top) { + ConnectionStatusBar() + } + } else { + AuthenticationView() + .environmentObject(authManager) } + + // Toast overlay + ToastOverlay() + } + .preferredColorScheme(colorScheme) + .fullScreenCover(isPresented: $showOnboarding) { + OnboardingView(isPresented: $showOnboarding) + } + .onAppear { + setupApp() + } + } + } + + var colorScheme: ColorScheme? { + switch preferredColorScheme { + case 1: return .light + case 2: return .dark + default: return nil } } - + private func setupApp() { // Configure app on launch authManager.loadStoredCredentials() - + + // Show onboarding for first-time users + if !hasCompletedOnboarding && !authManager.isAuthenticated { + showOnboarding = true + } + // Setup notification handlers NotificationManager.shared.requestAuthorization() - + NotificationManager.shared.setupNotificationCategories() + // Start WebSocket connection if authenticated if authManager.isAuthenticated { WebSocketManager.shared.connect() + + // Preload cache + Task { + await CacheManager.shared.pruneExpired() + } } } -} \ No newline at end of file +} + +// MARK: - App Delegate for Quick Actions +class AppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + // Setup Quick Actions + setupQuickActions() + return true + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + // Handle Quick Action if app was launched via one + if let shortcutItem = options.shortcutItem { + handleQuickAction(shortcutItem) + } + + return UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) + } + + func setupQuickActions() { + UIApplication.shared.shortcutItems = [ + UIApplicationShortcutItem( + type: "com.slurmmanager.viewJobs", + localizedTitle: "View Jobs", + localizedSubtitle: "See all running jobs", + icon: UIApplicationShortcutIcon(systemImageName: "list.bullet.rectangle"), + userInfo: nil + ), + UIApplicationShortcutItem( + type: "com.slurmmanager.launchJob", + localizedTitle: "Launch Job", + localizedSubtitle: "Submit a new job", + icon: UIApplicationShortcutIcon(systemImageName: "play.circle.fill"), + userInfo: nil + ), + UIApplicationShortcutItem( + type: "com.slurmmanager.refresh", + localizedTitle: "Refresh", + localizedSubtitle: "Update job status", + icon: UIApplicationShortcutIcon(systemImageName: "arrow.clockwise"), + userInfo: nil + ) + ] + } + + func handleQuickAction(_ shortcutItem: UIApplicationShortcutItem) { + switch shortcutItem.type { + case "com.slurmmanager.viewJobs": + QuickActionManager.shared.pendingAction = .viewJobs + case "com.slurmmanager.launchJob": + QuickActionManager.shared.pendingAction = .launchJob + case "com.slurmmanager.refresh": + QuickActionManager.shared.pendingAction = .refresh + default: + break + } + } + + func applicationWillResignActive(_ application: UIApplication) { + // Update dynamic shortcuts based on current state + updateDynamicShortcuts() + } + + func updateDynamicShortcuts() { + Task { + let cachedJobs = await CacheManager.shared.getAllJobs() + let runningCount = cachedJobs.filter { $0.state == .running }.count + + await MainActor.run { + var shortcuts = UIApplication.shared.shortcutItems ?? [] + + // Update or add running jobs shortcut + if runningCount > 0 { + let runningShortcut = UIApplicationShortcutItem( + type: "com.slurmmanager.runningJobs", + localizedTitle: "Running Jobs", + localizedSubtitle: "\(runningCount) jobs running", + icon: UIApplicationShortcutIcon(systemImageName: "play.fill"), + userInfo: nil + ) + + if let index = shortcuts.firstIndex(where: { $0.type == "com.slurmmanager.runningJobs" }) { + shortcuts[index] = runningShortcut + } else if shortcuts.count < 4 { + shortcuts.append(runningShortcut) + } + + UIApplication.shared.shortcutItems = shortcuts + } + } + } + } +} + +// MARK: - Quick Action Manager +class QuickActionManager: ObservableObject { + static let shared = QuickActionManager() + + enum QuickAction { + case viewJobs + case launchJob + case refresh + case runningJobs + } + + @Published var pendingAction: QuickAction? +} + +// MARK: - Main Tab View +struct MainTabView: View { + @State private var selectedTab = 0 + @StateObject private var quickActionManager = QuickActionManager.shared + @State private var showLaunchJob = false + + var body: some View { + TabView(selection: $selectedTab) { + // Jobs Tab + NavigationView { + JobListView() + } + .tabItem { + Label("Jobs", systemImage: "list.bullet.rectangle") + } + .tag(0) + + // Hosts Tab + NavigationView { + HostsView() + } + .tabItem { + Label("Hosts", systemImage: "server.rack") + } + .tag(1) + + // Launch Tab (opens sheet) + Text("") + .tabItem { + Label("Launch", systemImage: "plus.circle.fill") + } + .tag(2) + + // Settings Tab + SettingsView() + .tabItem { + Label("Settings", systemImage: "gearshape") + } + .tag(3) + } + .onChange(of: selectedTab) { newTab in + if newTab == 2 { + // Reset to previous tab and show launch sheet + selectedTab = 0 + showLaunchJob = true + } + } + .onChange(of: quickActionManager.pendingAction) { action in + handleQuickAction(action) + } + .sheet(isPresented: $showLaunchJob) { + LaunchJobView() + } + .onAppear { + // Check for pending quick action + if let action = quickActionManager.pendingAction { + handleQuickAction(action) + } + } + } + + func handleQuickAction(_ action: QuickAction?) { + guard let action = action else { return } + + DispatchQueue.main.async { + switch action { + case .viewJobs, .runningJobs: + selectedTab = 0 + case .launchJob: + selectedTab = 0 + showLaunchJob = true + case .refresh: + // Trigger refresh in JobListView + NotificationCenter.default.post(name: .refreshJobs, object: nil) + } + + // Clear the action + quickActionManager.pendingAction = nil + } + } +} + +// MARK: - Toast Overlay +struct ToastOverlay: View { + @StateObject private var toastManager = ToastManager.shared + + var body: some View { + VStack { + Spacer() + + if let toast = toastManager.currentToast { + ToastView( + message: toast.message, + type: toast.type + ) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .padding(.bottom, 100) + } + } + .animation(.spring(), value: toastManager.currentToast?.id) + } +} + +// MARK: - Notification Names +extension Notification.Name { + static let refreshJobs = Notification.Name("refreshJobs") + static let navigateToJob = Notification.Name("navigateToJob") +} diff --git a/ios-app/SlurmManager/SlurmManager/Views/JobDetailView.swift b/ios-app/SlurmManager/SlurmManager/Views/JobDetailView.swift index 4a1aa23..ef94409 100644 --- a/ios-app/SlurmManager/SlurmManager/Views/JobDetailView.swift +++ b/ios-app/SlurmManager/SlurmManager/Views/JobDetailView.swift @@ -1,112 +1,229 @@ import SwiftUI import Combine +// MARK: - Enhanced Job Detail View struct JobDetailView: View { let job: Job @StateObject private var viewModel = JobDetailViewModel() @State private var selectedTab = 0 + @State private var showCancelConfirmation = false + @State private var showShareSheet = false @Environment(\.dismiss) var dismiss - + var body: some View { ScrollView { - VStack(spacing: 20) { - // Job Header + VStack(spacing: 0) { + // Sticky Header jobHeader - - // Tab Selection - Picker("", selection: $selectedTab) { - Text("Info").tag(0) - Text("Script").tag(1) - Text("Output").tag(2) - Text("Error").tag(3) - } - .pickerStyle(SegmentedPickerStyle()) - .padding(.horizontal) - + .padding(.horizontal) + .padding(.top) + + // Quick Stats + quickStatsBar + .padding(.vertical, 12) + + // Tab Selection with smooth animation + tabSelector + .padding(.horizontal) + // Tab Content tabContent + .padding(.top, 16) } - .padding(.vertical) } + .background(Color(.systemGroupedBackground)) .navigationTitle("Job Details") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Menu { - Button(action: refreshJob) { + Button(action: { viewModel.loadJobDetail(job) }) { Label("Refresh", systemImage: "arrow.clockwise") } - + if job.isRunning { - Button(action: cancelJob) { + Button(role: .destructive, action: { showCancelConfirmation = true }) { Label("Cancel Job", systemImage: "xmark.circle") } - .foregroundColor(.red) } - - Button(action: shareJob) { + + Divider() + + Button(action: { showShareSheet = true }) { Label("Share", systemImage: "square.and.arrow.up") } + + Button(action: copyJobId) { + Label("Copy Job ID", systemImage: "doc.on.doc") + } } label: { Image(systemName: "ellipsis.circle") + .font(.title3) } } } + .confirmationDialog( + "Cancel Job", + isPresented: $showCancelConfirmation, + titleVisibility: .visible + ) { + Button("Cancel Job", role: .destructive) { + viewModel.cancelJob(job) + } + Button("Keep Running", role: .cancel) {} + } message: { + Text("Are you sure you want to cancel job \(job.name)?") + } + .sheet(isPresented: $showShareSheet) { + ShareSheet(items: [createShareText()]) + } .onAppear { viewModel.loadJobDetail(job) - WebSocketManager.shared.subscribeToJob(job.id) + if job.isRunning || job.state == .pending { + WebSocketManager.shared.subscribeToJob(job.id) + } } .onDisappear { WebSocketManager.shared.unsubscribeFromJob(job.id) } } - + + // MARK: - Job Header var jobHeader: some View { - VStack(spacing: 12) { + VStack(spacing: 16) { + // Status and ID row HStack { - Circle() - .fill(statusColor) - .frame(width: 12, height: 12) - - Text(job.state.displayName) - .font(.headline) - .foregroundColor(statusColor) - + AnimatedStatusBadge(state: job.state) + Spacer() - - Text(job.id) - .font(.caption) - .foregroundColor(.secondary) + + HStack(spacing: 4) { + Text("ID:") + .foregroundColor(.secondary) + Text(job.id) + .fontWeight(.medium) + } + .font(.caption) } - .padding(.horizontal) - + + // Job Name Text(job.name) .font(.title2) - .fontWeight(.semibold) + .fontWeight(.bold) .multilineTextAlignment(.center) - .padding(.horizontal) - + .frame(maxWidth: .infinity) + + // Live duration for running jobs + if job.isRunning { + LiveDurationDisplay(startTime: job.startTime) + } + + // Meta info HStack(spacing: 20) { - if let duration = job.formattedDuration { - Label(duration, systemImage: "timer") - .font(.caption) + MetaItem(icon: "person.fill", text: job.user) + + if let host = job.host.split(separator: ".").first { + MetaItem(icon: "server.rack", text: String(host)) + } + + if let partition = job.partition { + MetaItem(icon: "square.stack.3d.up", text: partition) } - - Label(job.user, systemImage: "person") - .font(.caption) - - Label(job.host.split(separator: ".").first.map(String.init) ?? job.host, - systemImage: "server.rack") - .font(.caption) } - .foregroundColor(.secondary) } - .padding() + .padding(20) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color(.secondarySystemGroupedBackground)) + .shadow(color: .black.opacity(0.05), radius: 10, y: 5) + ) + } + + // MARK: - Quick Stats + var quickStatsBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + if let cpus = job.cpus { + QuickStatCard(icon: "cpu", title: "CPUs", value: "\(cpus)") + } + + if let nodes = job.nodes { + QuickStatCard(icon: "rectangle.stack", title: "Nodes", value: nodes) + } + + if let memory = job.memory { + QuickStatCard(icon: "memorychip", title: "Memory", value: memory) + } + + if let timeLimit = job.timeLimit { + QuickStatCard(icon: "clock", title: "Time Limit", value: timeLimit) + } + + if let qos = job.qos { + QuickStatCard(icon: "gauge.medium", title: "QoS", value: qos) + } + } + .padding(.horizontal) + } + } + + // MARK: - Tab Selector + var tabSelector: some View { + HStack(spacing: 0) { + ForEach(Tab.allCases, id: \.self) { tab in + TabButton( + title: tab.title, + icon: tab.icon, + isSelected: selectedTab == tab.rawValue, + hasContent: tabHasContent(tab) + ) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + selectedTab = tab.rawValue + } + HapticManager.selection() + } + } + } + .padding(4) .background(Color(.systemGray6)) .cornerRadius(12) - .padding(.horizontal) } - + + enum Tab: Int, CaseIterable { + case info = 0 + case script = 1 + case output = 2 + case error = 3 + + var title: String { + switch self { + case .info: return "Info" + case .script: return "Script" + case .output: return "Output" + case .error: return "Errors" + } + } + + var icon: String { + switch self { + case .info: return "info.circle" + case .script: return "doc.text" + case .output: return "terminal" + case .error: return "exclamationmark.triangle" + } + } + } + + func tabHasContent(_ tab: Tab) -> Bool { + switch tab { + case .info: return true + case .script: return viewModel.script != nil + case .output: return viewModel.output != nil && !viewModel.output!.isEmpty + case .error: return viewModel.error != nil && !viewModel.error!.isEmpty + } + } + + // MARK: - Tab Content @ViewBuilder var tabContent: some View { switch selectedTab { @@ -115,166 +232,516 @@ struct JobDetailView: View { case 1: scriptView case 2: - outputView(type: .stdout) + outputView(content: viewModel.output, type: "stdout") case 3: - outputView(type: .stderr) + outputView(content: viewModel.error, type: "stderr") default: EmptyView() } } - + + // MARK: - Job Info View var jobInfoView: some View { VStack(spacing: 16) { - InfoRow(label: "Job ID", value: job.id) - InfoRow(label: "Name", value: job.name) - InfoRow(label: "User", value: job.user) - InfoRow(label: "State", value: job.state.displayName) - - if let partition = job.partition { - InfoRow(label: "Partition", value: partition) - } - - if let nodes = job.nodes { - InfoRow(label: "Nodes", value: nodes) - } - - if let cpus = job.cpus { - InfoRow(label: "CPUs", value: "\(cpus)") - } - - if let memory = job.memory { - InfoRow(label: "Memory", value: memory) - } - - if let submitTime = job.submitTime { - InfoRow(label: "Submitted", value: formatDate(submitTime)) - } - - if let startTime = job.startTime { - InfoRow(label: "Started", value: formatDate(startTime)) + // Timeline section + InfoSection(title: "Timeline") { + VStack(spacing: 12) { + if let submitTime = job.submitTime { + TimelineRow( + icon: "paperplane.fill", + title: "Submitted", + date: submitTime, + color: .blue + ) + } + + if let startTime = job.startTime { + TimelineRow( + icon: "play.fill", + title: "Started", + date: startTime, + color: .green + ) + } + + if let endTime = job.endTime { + TimelineRow( + icon: "stop.fill", + title: "Ended", + date: endTime, + color: job.state == .completed ? .green : .red + ) + } + + if let duration = job.formattedDuration { + JobInfoRow(label: "Duration", value: duration, icon: "timer") + } + } } - - if let endTime = job.endTime { - InfoRow(label: "Ended", value: formatDate(endTime)) + + // Resources section + InfoSection(title: "Resources") { + VStack(spacing: 12) { + if let cpus = job.cpus { + JobInfoRow(label: "CPUs", value: "\(cpus)", icon: "cpu") + } + + if let nodes = job.nodes { + JobInfoRow(label: "Nodes", value: nodes, icon: "rectangle.stack") + } + + if let memory = job.memory { + JobInfoRow(label: "Memory", value: memory, icon: "memorychip") + } + + if let timeLimit = job.timeLimit { + JobInfoRow(label: "Time Limit", value: timeLimit, icon: "clock") + } + } } - - if let workDir = job.workDir { - InfoRow(label: "Work Directory", value: workDir) + + // Details section + InfoSection(title: "Details") { + VStack(spacing: 12) { + if let partition = job.partition { + JobInfoRow(label: "Partition", value: partition, icon: "square.stack.3d.up") + } + + if let qos = job.qos { + JobInfoRow(label: "QoS", value: qos, icon: "gauge.medium") + } + + if let account = job.account { + JobInfoRow(label: "Account", value: account, icon: "person.2") + } + + if let workDir = job.workDir { + JobInfoRow(label: "Work Directory", value: workDir, icon: "folder") + } + + JobInfoRow(label: "Host", value: job.host, icon: "server.rack") + } } } .padding(.horizontal) } - + + // MARK: - Script View var scriptView: some View { Group { if viewModel.isLoadingScript { - ProgressView("Loading script...") - .frame(maxWidth: .infinity, minHeight: 200) - } else if let script = viewModel.script { - ScrollView(.horizontal) { - Text(script) - .font(.system(.caption, design: .monospaced)) - .padding() - .frame(maxWidth: .infinity, alignment: .leading) + VStack(spacing: 16) { + ProgressView() + Text("Loading script...") + .foregroundColor(.secondary) } - .background(Color(.systemGray6)) - .cornerRadius(8) + .frame(maxWidth: .infinity, minHeight: 200) + } else if let script = viewModel.script { + CodeViewer( + content: script, + language: "bash", + showLineNumbers: true + ) .padding(.horizontal) } else { - Text("Script not available") - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, minHeight: 200) + EmptyContentView( + icon: "doc.text.magnifyingglass", + title: "No Script Available", + message: "Script content is not available for this job" + ) } } } - - func outputView(type: OutputType) -> some View { + + // MARK: - Output View + func outputView(content: String?, type: String) -> some View { Group { if viewModel.isLoadingOutput { - ProgressView("Loading output...") - .frame(maxWidth: .infinity, minHeight: 200) - } else { - let content = type == .stdout ? viewModel.output : viewModel.error - if let content = content, !content.isEmpty { - ScrollView { - Text(content) - .font(.system(.caption, design: .monospaced)) - .padding() - .frame(maxWidth: .infinity, alignment: .leading) + VStack(spacing: 16) { + ProgressView() + Text("Loading \(type)...") + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 200) + } else if let content = content, !content.isEmpty { + VStack(alignment: .leading, spacing: 8) { + // Header with actions + HStack { + if job.isRunning { + LiveIndicator() + } + + Spacer() + + Button(action: { copyToClipboard(content) }) { + Label("Copy", systemImage: "doc.on.doc") + .font(.caption) + } + .buttonStyle(.bordered) + .controlSize(.small) } - .background(Color(.systemGray6)) - .cornerRadius(8) .padding(.horizontal) - } else { - Text("No \(type == .stdout ? "output" : "error") available") - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, minHeight: 200) + + // Output content + CodeViewer( + content: content, + language: "log", + showLineNumbers: true, + autoScroll: job.isRunning + ) + .padding(.horizontal) } + } else { + EmptyContentView( + icon: type == "stdout" ? "terminal" : "exclamationmark.triangle", + title: "No \(type == "stdout" ? "Output" : "Errors")", + message: job.isRunning + ? "Output will appear here as the job runs" + : "No \(type) was generated by this job" + ) } } } - - var statusColor: Color { - switch job.state { - case .running: return .blue - case .pending: return .orange - case .completed: return .green - case .failed, .timeout, .nodeFailure, .outOfMemory: return .red - case .cancelled: return .gray - default: return .gray - } - } - - private func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .medium - return formatter.string(from: date) - } - - private func refreshJob() { - viewModel.loadJobDetail(job) + + // MARK: - Helper Methods + private func copyJobId() { + UIPasteboard.general.string = job.id + HapticManager.notification(.success) + ToastManager.shared.show("Job ID copied", type: .success) } - - private func cancelJob() { - viewModel.cancelJob(job) + + private func copyToClipboard(_ text: String) { + UIPasteboard.general.string = text + HapticManager.notification(.success) + ToastManager.shared.show("Copied to clipboard", type: .success) } - - private func shareJob() { - let text = """ - Job: \(job.name) - ID: \(job.id) - State: \(job.state.displayName) + + private func createShareText() -> String { + """ + SLURM Job: \(job.name) + Job ID: \(job.id) + Status: \(job.state.displayName) Host: \(job.host) + User: \(job.user) + \(job.partition.map { "Partition: \($0)" } ?? "") + \(job.formattedDuration.map { "Duration: \($0)" } ?? "") """ - - let av = UIActivityViewController(activityItems: [text], applicationActivities: nil) - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first { - window.rootViewController?.present(av, animated: true) + } +} + +// MARK: - Supporting Views + +struct MetaItem: View { + let icon: String + let text: String + + var body: some View { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.caption) + Text(text) + .font(.caption) + } + .foregroundColor(.secondary) + } +} + +struct QuickStatCard: View { + let icon: String + let title: String + let value: String + + var body: some View { + VStack(spacing: 4) { + Image(systemName: icon) + .font(.title3) + .foregroundColor(.blue) + + Text(value) + .font(.subheadline) + .fontWeight(.semibold) + + Text(title) + .font(.caption2) + .foregroundColor(.secondary) } + .frame(width: 70) + .padding(.vertical, 12) + .background(Color(.secondarySystemGroupedBackground)) + .cornerRadius(12) } } -struct InfoRow: View { +struct TabButton: View { + let title: String + let icon: String + let isSelected: Bool + let hasContent: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.caption) + Text(title) + .font(.subheadline) + .fontWeight(isSelected ? .semibold : .regular) + } + .foregroundColor(isSelected ? .white : (hasContent ? .primary : .secondary)) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(isSelected ? Color.blue : Color.clear) + .cornerRadius(8) + } + } +} + +struct InfoSection: View { + let title: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.headline) + .padding(.horizontal, 4) + + VStack(spacing: 0) { + content + } + .padding(16) + .background(Color(.secondarySystemGroupedBackground)) + .cornerRadius(12) + } + } +} + +struct JobInfoRow: View { let label: String let value: String - + var icon: String? = nil + var body: some View { HStack { + if let icon = icon { + Image(systemName: icon) + .font(.caption) + .foregroundColor(.secondary) + .frame(width: 20) + } + Text(label) - .font(.caption) + .font(.subheadline) .foregroundColor(.secondary) + Spacer() + Text(value) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + } + } +} + +struct TimelineRow: View { + let icon: String + let title: String + let date: Date + let color: Color + + var body: some View { + HStack(spacing: 12) { + Circle() + .fill(color.opacity(0.2)) + .frame(width: 32, height: 32) + .overlay( + Image(systemName: icon) + .font(.caption) + .foregroundColor(color) + ) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption) + .foregroundColor(.secondary) + Text(formatDate(date)) + .font(.subheadline) + .fontWeight(.medium) + } + + Spacer() + + Text(relativeTime(date)) .font(.caption) - .multilineTextAlignment(.trailing) + .foregroundColor(.secondary) + } + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: date) + } + + private func relativeTime(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } +} + +struct LiveDurationDisplay: View { + let startTime: Date? + @State private var currentTime = Date() + + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(Color.green) + .frame(width: 8, height: 8) + .pulseAnimation() + + Text(formattedDuration) + .font(.system(.title3, design: .monospaced)) + .fontWeight(.bold) + .foregroundColor(.blue) + } + .onReceive(timer) { time in + currentTime = time } + } + + var formattedDuration: String { + guard let start = startTime else { return "--:--:--" } + let duration = currentTime.timeIntervalSince(start) + + let hours = Int(duration) / 3600 + let minutes = (Int(duration) % 3600) / 60 + let seconds = Int(duration) % 60 + + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } +} + +struct LiveIndicator: View { + @State private var isAnimating = false + + var body: some View { + HStack(spacing: 4) { + Circle() + .fill(Color.red) + .frame(width: 6, height: 6) + .scaleEffect(isAnimating ? 1.0 : 0.6) + .animation( + .easeInOut(duration: 0.8) + .repeatForever(autoreverses: true), + value: isAnimating + ) + + Text("LIVE") + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(.red) + } + .padding(.horizontal, 8) .padding(.vertical, 4) + .background(Color.red.opacity(0.1)) + .cornerRadius(4) + .onAppear { + isAnimating = true + } + } +} + +struct EmptyContentView: View { + let icon: String + let title: String + let message: String + + var body: some View { + VStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 48)) + .foregroundColor(.secondary) + + Text(title) + .font(.headline) + + Text(message) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, minHeight: 200) + .padding() + } +} + +struct CodeViewer: View { + let content: String + var language: String = "text" + var showLineNumbers: Bool = true + var autoScroll: Bool = false + + var body: some View { + ScrollView([.horizontal, .vertical]) { + ScrollViewReader { proxy in + VStack(alignment: .leading, spacing: 0) { + let lines = content.components(separatedBy: "\n") + + ForEach(Array(lines.enumerated()), id: \.offset) { index, line in + HStack(alignment: .top, spacing: 8) { + if showLineNumbers { + Text("\(index + 1)") + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + .frame(width: 30, alignment: .trailing) + } + + Text(line.isEmpty ? " " : line) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.primary) + } + .padding(.vertical, 2) + .id(index) + } + } + .padding(12) + .onAppear { + if autoScroll { + proxy.scrollTo(content.components(separatedBy: "\n").count - 1) + } + } + .onChange(of: content) { newContent in + if autoScroll { + withAnimation { + proxy.scrollTo(newContent.components(separatedBy: "\n").count - 1) + } + } + } + } + } + .background(Color(.systemGray6)) + .cornerRadius(8) } } +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} + +// MARK: - View Model @MainActor class JobDetailViewModel: ObservableObject { @Published var jobDetail: JobDetail? @@ -283,9 +750,10 @@ class JobDetailViewModel: ObservableObject { @Published var error: String? @Published var isLoadingScript = false @Published var isLoadingOutput = false - + private var cancellables = Set() - + private var outputTimer: Timer? + func loadJobDetail(_ job: Job) { // Load full job details JobManager.shared.fetchJobDetail(jobId: job.id, host: job.host) @@ -299,29 +767,32 @@ class JobDetailViewModel: ObservableObject { } ) .store(in: &cancellables) - + // Load script if not included loadScript(job) - + // Load output if job is running or completed if job.isRunning || job.isCompleted { loadOutput(job) } + + // Start polling for running jobs + if job.isRunning { + startOutputPolling(job) + } } - + func loadScript(_ job: Job) { isLoadingScript = true - - // In a real app, this would call an API endpoint for the script - // For now, we'll use the detail endpoint - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.isLoadingScript = false + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.isLoadingScript = false } } - + func loadOutput(_ job: Job) { isLoadingOutput = true - + APIClient.shared.getJobOutput(jobId: job.id, host: job.host, outputType: .both) .sink( receiveCompletion: { [weak self] _ in @@ -334,17 +805,60 @@ class JobDetailViewModel: ObservableObject { ) .store(in: &cancellables) } - + func cancelJob(_ job: Job) { JobManager.shared.cancelJob(jobId: job.id, host: job.host) .sink( receiveCompletion: { _ in }, receiveValue: { success in if success { - // Job cancelled successfully + HapticManager.notification(.success) + ToastManager.shared.show("Job cancelled", type: .success) + } else { + HapticManager.notification(.error) + ToastManager.shared.show("Failed to cancel job", type: .error) } } ) .store(in: &cancellables) } -} \ No newline at end of file + + private func startOutputPolling(_ job: Job) { + outputTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in + self?.loadOutput(job) + } + } + + deinit { + outputTimer?.invalidate() + } +} + +// MARK: - Preview +#Preview { + NavigationView { + JobDetailView( + job: Job( + id: "12345", + name: "training_model_v2", + user: "jsmith", + state: .running, + submitTime: Date().addingTimeInterval(-3600), + startTime: Date().addingTimeInterval(-1800), + endTime: nil, + partition: "gpu", + nodes: "4", + cpus: 32, + memory: "128G", + timeLimit: "24:00:00", + workDir: "/home/jsmith/projects/ml-training", + command: nil, + array: nil, + qos: "normal", + account: "research", + host: "cluster1.example.com", + cached: false + ) + ) + } +} diff --git a/ios-app/SlurmManager/SlurmManager/Views/LaunchJobView.swift b/ios-app/SlurmManager/SlurmManager/Views/LaunchJobView.swift index 37a94e6..3078216 100644 --- a/ios-app/SlurmManager/SlurmManager/Views/LaunchJobView.swift +++ b/ios-app/SlurmManager/SlurmManager/Views/LaunchJobView.swift @@ -1,39 +1,783 @@ import SwiftUI +import Combine +// MARK: - Launch Job View struct LaunchJobView: View { - @State private var scriptPath = "" - @State private var sourceDir = "" - @State private var selectedHost = "" - @State private var jobName = "" - + @StateObject private var viewModel = LaunchJobViewModel() + @Environment(\.dismiss) var dismiss + + @State private var showingScriptEditor = false + @State private var showingRecentScripts = false + @State private var showingValidationError = false + var body: some View { NavigationView { - Form { - Section("Job Configuration") { - TextField("Job Name", text: $jobName) - TextField("Script Path", text: $scriptPath) - TextField("Source Directory", text: $sourceDir) - Picker("Host", selection: $selectedHost) { - Text("Select Host").tag("") - } - } - - Section { - Button(action: launchJob) { + ScrollView { + VStack(spacing: 24) { + // Header card + headerCard + + // Host selection + hostSelectionSection + + // Script configuration + scriptSection + + // Resource configuration + resourceSection + + // Advanced options (collapsible) + advancedSection + + // Launch button + launchButton + + Spacer(minLength: 32) + } + .padding() + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("Launch Job") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + Button(action: { showingRecentScripts = true }) { + Label("Recent Scripts", systemImage: "clock.arrow.circlepath") + } + + Button(action: viewModel.resetToDefaults) { + Label("Reset to Defaults", systemImage: "arrow.counterclockwise") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .onAppear { + viewModel.loadHosts() + } + .alert("Validation Error", isPresented: $showingValidationError) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.validationError ?? "Please check your input") + } + .sheet(isPresented: $showingScriptEditor) { + ScriptEditorView(script: $viewModel.scriptContent) + } + .sheet(isPresented: $showingRecentScripts) { + RecentScriptsView(onSelect: { script in + viewModel.scriptContent = script.content + viewModel.jobName = script.name + showingRecentScripts = false + }) + } + } + } + + // MARK: - Header Card + var headerCard: some View { + VStack(spacing: 12) { + Image(systemName: "play.circle.fill") + .font(.system(size: 48)) + .foregroundColor(.blue) + + Text("Launch New Job") + .font(.title2) + .fontWeight(.bold) + + Text("Configure and submit a SLURM job to your cluster") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(24) + .frame(maxWidth: .infinity) + .background(Color(.secondarySystemGroupedBackground)) + .cornerRadius(16) + } + + // MARK: - Host Selection + var hostSelectionSection: some View { + FormSection(title: "Cluster", icon: "server.rack") { + if viewModel.isLoadingHosts { + HStack { + ProgressView() + Text("Loading hosts...") + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding() + } else if viewModel.hosts.isEmpty { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.title2) + .foregroundColor(.orange) + Text("No hosts available") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding() + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(viewModel.hosts) { host in + HostSelectionCard( + host: host, + isSelected: viewModel.selectedHost?.id == host.id + ) { + withAnimation(.spring()) { + viewModel.selectedHost = host + } + HapticManager.selection() + } + } + } + .padding(.horizontal, 4) + .padding(.vertical, 8) + } + } + } + } + + // MARK: - Script Section + var scriptSection: some View { + FormSection(title: "Script", icon: "doc.text") { + VStack(spacing: 16) { + // Job name + FormTextField( + label: "Job Name", + placeholder: "my_job", + text: $viewModel.jobName, + icon: "tag" + ) + + // Script content or path + VStack(alignment: .leading, spacing: 8) { + Text("Script Content") + .font(.subheadline) + .foregroundColor(.secondary) + + Button(action: { showingScriptEditor = true }) { HStack { - Image(systemName: "play.circle.fill") - Text("Launch Job") + Image(systemName: viewModel.scriptContent.isEmpty ? "plus.circle" : "pencil.circle") + .font(.title3) + + VStack(alignment: .leading, spacing: 2) { + Text(viewModel.scriptContent.isEmpty ? "Add Script" : "Edit Script") + .font(.subheadline) + .fontWeight(.medium) + + if !viewModel.scriptContent.isEmpty { + Text("\(viewModel.scriptContent.components(separatedBy: "\n").count) lines") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) } - .frame(maxWidth: .infinity) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) } - .disabled(scriptPath.isEmpty || sourceDir.isEmpty || selectedHost.isEmpty) + .buttonStyle(.plain) } + + // Source directory (optional) + FormTextField( + label: "Source Directory (Optional)", + placeholder: "/path/to/source", + text: $viewModel.sourceDir, + icon: "folder" + ) } - .navigationTitle("Launch Job") } } - + + // MARK: - Resource Section + var resourceSection: some View { + FormSection(title: "Resources", icon: "cpu") { + VStack(spacing: 16) { + // CPUs + FormStepper( + label: "CPUs", + value: $viewModel.cpus, + range: 1...128, + icon: "bolt.fill" + ) + + // Memory + FormTextField( + label: "Memory", + placeholder: "8G", + text: $viewModel.memory, + icon: "memorychip" + ) + + // Time limit + FormTextField( + label: "Time Limit", + placeholder: "1:00:00", + text: $viewModel.timeLimit, + icon: "clock" + ) + + // Nodes + FormStepper( + label: "Nodes", + value: $viewModel.nodes, + range: 1...64, + icon: "rectangle.stack" + ) + + // GPUs (if available) + if viewModel.selectedHost?.workDir.contains("gpu") == true { + FormStepper( + label: "GPUs per Node", + value: $viewModel.gpusPerNode, + range: 0...8, + icon: "cpu" + ) + } + } + } + } + + // MARK: - Advanced Section + var advancedSection: some View { + ExpandableSection(title: "Advanced Options", icon: "slider.horizontal.3") { + VStack(spacing: 16) { + // Partition + FormTextField( + label: "Partition", + placeholder: "batch", + text: $viewModel.partition, + icon: "square.stack.3d.up" + ) + + // Account + FormTextField( + label: "Account", + placeholder: "default", + text: $viewModel.account, + icon: "person.2" + ) + + // QoS + FormTextField( + label: "QoS", + placeholder: "normal", + text: $viewModel.qos, + icon: "gauge.medium" + ) + + // Sync options + Toggle(isOn: $viewModel.syncBeforeLaunch) { + Label("Sync source directory", systemImage: "arrow.triangle.2.circlepath") + } + + if viewModel.syncBeforeLaunch { + Toggle(isOn: $viewModel.respectGitignore) { + Label("Respect .gitignore", systemImage: "doc.badge.gearshape") + } + } + } + } + } + + // MARK: - Launch Button + var launchButton: some View { + Button(action: { + if viewModel.validate() { + viewModel.launchJob() + } else { + showingValidationError = true + } + }) { + HStack { + if viewModel.isLaunching { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Image(systemName: "play.fill") + } + + Text(viewModel.isLaunching ? "Launching..." : "Launch Job") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding() + .background(viewModel.canLaunch ? Color.blue : Color.gray) + .foregroundColor(.white) + .cornerRadius(12) + } + .disabled(!viewModel.canLaunch || viewModel.isLaunching) + } +} + +// MARK: - Form Components + +struct FormSection: View { + let title: String + let icon: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Label(title, systemImage: icon) + .font(.headline) + + VStack(spacing: 0) { + content + } + .padding(16) + .background(Color(.secondarySystemGroupedBackground)) + .cornerRadius(12) + } + } +} + +struct ExpandableSection: View { + let title: String + let icon: String + @ViewBuilder let content: Content + @State private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Button(action: { + withAnimation(.spring()) { + isExpanded.toggle() + } + HapticManager.selection() + }) { + HStack { + Label(title, systemImage: icon) + .font(.headline) + .foregroundColor(.primary) + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + } + + if isExpanded { + VStack(spacing: 0) { + content + } + .padding(16) + .background(Color(.secondarySystemGroupedBackground)) + .cornerRadius(12) + .transition(.asymmetric( + insertion: .scale(scale: 0.95).combined(with: .opacity), + removal: .opacity + )) + } + } + } +} + +struct FormTextField: View { + let label: String + let placeholder: String + @Binding var text: String + var icon: String? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(label) + .font(.subheadline) + .foregroundColor(.secondary) + + HStack(spacing: 10) { + if let icon = icon { + Image(systemName: icon) + .font(.subheadline) + .foregroundColor(.secondary) + .frame(width: 20) + } + + TextField(placeholder, text: $text) + .textFieldStyle(.plain) + } + .padding(12) + .background(Color(.systemGray6)) + .cornerRadius(8) + } + } +} + +struct FormStepper: View { + let label: String + @Binding var value: Int + let range: ClosedRange + var icon: String? = nil + + var body: some View { + HStack { + if let icon = icon { + Image(systemName: icon) + .font(.subheadline) + .foregroundColor(.secondary) + .frame(width: 20) + } + + Text(label) + .font(.subheadline) + + Spacer() + + HStack(spacing: 12) { + Button(action: { + if value > range.lowerBound { + value -= 1 + HapticManager.selection() + } + }) { + Image(systemName: "minus.circle.fill") + .font(.title2) + .foregroundColor(value > range.lowerBound ? .blue : .gray) + } + .disabled(value <= range.lowerBound) + + Text("\(value)") + .font(.headline) + .frame(width: 40) + + Button(action: { + if value < range.upperBound { + value += 1 + HapticManager.selection() + } + }) { + Image(systemName: "plus.circle.fill") + .font(.title2) + .foregroundColor(value < range.upperBound ? .blue : .gray) + } + .disabled(value >= range.upperBound) + } + } + } +} + +struct HostSelectionCard: View { + let host: Host + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + ZStack { + Circle() + .fill(host.isAvailable ? (isSelected ? Color.blue : Color.gray.opacity(0.2)) : Color.red.opacity(0.2)) + .frame(width: 50, height: 50) + + Image(systemName: "server.rack") + .font(.title3) + .foregroundColor(host.isAvailable ? (isSelected ? .white : .primary) : .red) + } + + Text(host.displayName) + .font(.caption) + .fontWeight(isSelected ? .semibold : .regular) + .foregroundColor(.primary) + .lineLimit(1) + + Circle() + .fill(host.isAvailable ? Color.green : Color.red) + .frame(width: 8, height: 8) + } + .frame(width: 80) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(isSelected ? Color.blue.opacity(0.1) : Color.clear) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(isSelected ? Color.blue : Color.clear, lineWidth: 2) + ) + ) + } + .buttonStyle(.plain) + .disabled(!host.isAvailable) + } +} + +// MARK: - Script Editor View +struct ScriptEditorView: View { + @Binding var script: String + @Environment(\.dismiss) var dismiss + @State private var tempScript: String = "" + + var body: some View { + NavigationView { + VStack(spacing: 0) { + // Toolbar + HStack { + Button("Template") { + tempScript = defaultTemplate + } + .buttonStyle(.bordered) + + Spacer() + + Text("\(tempScript.components(separatedBy: "\n").count) lines") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + + // Editor + TextEditor(text: $tempScript) + .font(.system(.body, design: .monospaced)) + .autocapitalization(.none) + .disableAutocorrection(true) + } + .navigationTitle("Script Editor") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + script = tempScript + dismiss() + } + .fontWeight(.semibold) + } + } + .onAppear { + tempScript = script + } + } + } + + var defaultTemplate: String { + """ + #!/bin/bash + #SBATCH --job-name=my_job + #SBATCH --output=%j.out + #SBATCH --error=%j.err + #SBATCH --time=1:00:00 + #SBATCH --ntasks=1 + #SBATCH --cpus-per-task=4 + #SBATCH --mem=8G + + # Load modules + # module load python/3.9 + + # Run your code + echo "Starting job" + + # Your commands here + + echo "Job completed" + """ + } +} + +// MARK: - Recent Scripts View +struct RecentScriptsView: View { + let onSelect: (RecentScript) -> Void + @Environment(\.dismiss) var dismiss + + var body: some View { + NavigationView { + List { + Text("No recent scripts") + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } + .navigationTitle("Recent Scripts") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + } +} + +struct RecentScript: Identifiable { + let id = UUID() + let name: String + let content: String + let date: Date +} + +// MARK: - View Model +@MainActor +class LaunchJobViewModel: ObservableObject { + // Host selection + @Published var hosts: [Host] = [] + @Published var selectedHost: Host? + @Published var isLoadingHosts = false + + // Job configuration + @Published var jobName = "" + @Published var scriptContent = "" + @Published var sourceDir = "" + + // Resources + @Published var cpus = 4 + @Published var memory = "8G" + @Published var timeLimit = "1:00:00" + @Published var nodes = 1 + @Published var gpusPerNode = 0 + + // Advanced + @Published var partition = "" + @Published var account = "" + @Published var qos = "" + @Published var syncBeforeLaunch = false + @Published var respectGitignore = true + + // State + @Published var isLaunching = false + @Published var validationError: String? + + private var cancellables = Set() + + var canLaunch: Bool { + selectedHost != nil && + !jobName.isEmpty && + !scriptContent.isEmpty + } + + func loadHosts() { + isLoadingHosts = true + + APIClient.shared.getHosts() + .sink( + receiveCompletion: { [weak self] completion in + self?.isLoadingHosts = false + if case .failure(let error) = completion { + print("Failed to load hosts: \(error)") + } + }, + receiveValue: { [weak self] hosts in + self?.hosts = hosts + // Auto-select default host + if let defaultHost = hosts.first(where: { $0.isDefault && $0.isAvailable }) { + self?.selectedHost = defaultHost + } else if let firstAvailable = hosts.first(where: { $0.isAvailable }) { + self?.selectedHost = firstAvailable + } + } + ) + .store(in: &cancellables) + } + + func validate() -> Bool { + if selectedHost == nil { + validationError = "Please select a host" + return false + } + + if jobName.isEmpty { + validationError = "Please enter a job name" + return false + } + + if scriptContent.isEmpty { + validationError = "Please add script content" + return false + } + + validationError = nil + return true + } + + func resetToDefaults() { + jobName = "" + scriptContent = "" + sourceDir = "" + cpus = 4 + memory = "8G" + timeLimit = "1:00:00" + nodes = 1 + gpusPerNode = 0 + partition = "" + account = "" + qos = "" + syncBeforeLaunch = false + respectGitignore = true + } + func launchJob() { - // Implementation would submit job via API + guard let host = selectedHost else { return } + + isLaunching = true + + // Build launch request + let request = LaunchJobRequest( + scriptPath: "", + sourceDir: sourceDir.isEmpty ? nil : sourceDir, + host: host.hostname, + jobName: jobName, + slurmParams: SlurmParameters( + partition: partition.isEmpty ? nil : partition, + nodes: nodes, + cpus: cpus, + memory: memory, + time: timeLimit, + gpus: gpusPerNode > 0 ? gpusPerNode : nil, + account: account.isEmpty ? nil : account, + qos: qos.isEmpty ? nil : qos, + array: nil, + exclusive: nil + ), + syncSettings: syncBeforeLaunch ? SyncSettings( + excludePatterns: nil, + includePatterns: nil, + dryRun: false + ) : nil + ) + + APIClient.shared.launchJob(request) + .sink( + receiveCompletion: { [weak self] completion in + self?.isLaunching = false + if case .failure(let error) = completion { + HapticManager.notification(.error) + ToastManager.shared.show("Failed to launch job: \(error.localizedDescription)", type: .error) + } + }, + receiveValue: { [weak self] response in + HapticManager.notification(.success) + ToastManager.shared.show("Job \(response.jobId) submitted successfully", type: .success) + self?.resetToDefaults() + } + ) + .store(in: &cancellables) } -} \ No newline at end of file +} + +// MARK: - Preview +#Preview { + LaunchJobView() +} diff --git a/ios-app/SlurmManager/SlurmManager/Views/OnboardingView.swift b/ios-app/SlurmManager/SlurmManager/Views/OnboardingView.swift new file mode 100644 index 0000000..e46e5f7 --- /dev/null +++ b/ios-app/SlurmManager/SlurmManager/Views/OnboardingView.swift @@ -0,0 +1,438 @@ +import SwiftUI + +// MARK: - Onboarding View +struct OnboardingView: View { + @Binding var isPresented: Bool + @State private var currentPage = 0 + @State private var serverURL = "https://localhost:8042" + @State private var apiKey = "" + @State private var isConnecting = false + @State private var connectionError: String? + + let pages: [OnboardingPage] = [ + OnboardingPage( + title: "Welcome to\nSLURM Manager", + subtitle: "Monitor and manage your HPC jobs from anywhere", + icon: "server.rack", + color: .blue + ), + OnboardingPage( + title: "Real-time Updates", + subtitle: "Watch your jobs run with live status updates and output streaming", + icon: "bolt.fill", + color: .orange + ), + OnboardingPage( + title: "Launch Jobs", + subtitle: "Submit new jobs directly from your phone with full resource configuration", + icon: "play.circle.fill", + color: .green + ), + OnboardingPage( + title: "Stay Notified", + subtitle: "Get notified when jobs complete, fail, or need attention", + icon: "bell.badge.fill", + color: .purple + ) + ] + + var body: some View { + VStack(spacing: 0) { + // Page content + TabView(selection: $currentPage) { + ForEach(0.. some View { + VStack(spacing: 40) { + Spacer() + + // Icon + ZStack { + Circle() + .fill(page.color.opacity(0.15)) + .frame(width: 160, height: 160) + + Circle() + .fill(page.color.opacity(0.1)) + .frame(width: 120, height: 120) + + Image(systemName: page.icon) + .font(.system(size: 56)) + .foregroundColor(page.color) + } + + VStack(spacing: 16) { + Text(page.title) + .font(.largeTitle) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + Text(page.subtitle) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + + Spacer() + Spacer() + } + } + + // MARK: - Setup View + var setupView: some View { + ScrollView { + VStack(spacing: 32) { + // Header + VStack(spacing: 16) { + Image(systemName: "link.circle.fill") + .font(.system(size: 64)) + .foregroundColor(.blue) + + Text("Connect to Server") + .font(.title) + .fontWeight(.bold) + + Text("Enter your ssync server details to get started") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .padding(.top, 40) + + // Form + VStack(spacing: 20) { + // Server URL + VStack(alignment: .leading, spacing: 8) { + Label("Server URL", systemImage: "globe") + .font(.subheadline) + .foregroundColor(.secondary) + + TextField("https://example.com:8042", text: $serverURL) + .textFieldStyle(.plain) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + } + + // API Key + VStack(alignment: .leading, spacing: 8) { + Label("API Key", systemImage: "key.fill") + .font(.subheadline) + .foregroundColor(.secondary) + + SecureField("Enter your API key", text: $apiKey) + .textFieldStyle(.plain) + .autocapitalization(.none) + .disableAutocorrection(true) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + } + + // Help text + HStack { + Image(systemName: "info.circle") + .font(.caption) + Text("Run `ssync auth create-key` on your server to generate an API key") + .font(.caption) + } + .foregroundColor(.secondary) + .padding(.horizontal) + + // Error message + if let error = connectionError { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text(error) + .font(.caption) + .foregroundColor(.red) + } + .padding() + .background(Color.red.opacity(0.1)) + .cornerRadius(8) + } + } + .padding(.horizontal, 24) + + Spacer(minLength: 100) + } + } + } + + // MARK: - Bottom Navigation + var bottomNavigation: some View { + VStack(spacing: 20) { + // Page indicators + HStack(spacing: 8) { + ForEach(0.. 0 { + Button(action: { + withAnimation { + currentPage -= 1 + } + }) { + Text("Back") + .fontWeight(.medium) + .frame(maxWidth: .infinity) + .padding() + .background(Color(.systemGray6)) + .foregroundColor(.primary) + .cornerRadius(12) + } + } else { + Button(action: { + withAnimation { + currentPage = pages.count // Skip to setup + } + }) { + Text("Skip") + .fontWeight(.medium) + .frame(maxWidth: .infinity) + .padding() + .background(Color(.systemGray6)) + .foregroundColor(.primary) + .cornerRadius(12) + } + } + + // Next/Connect button + Button(action: { + if currentPage < pages.count { + withAnimation { + currentPage += 1 + } + } else { + connect() + } + }) { + HStack { + if isConnecting { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(0.8) + } else { + Text(currentPage == pages.count ? "Connect" : "Next") + .fontWeight(.semibold) + + if currentPage < pages.count { + Image(systemName: "arrow.right") + } + } + } + .frame(maxWidth: .infinity) + .padding() + .background(canConnect ? Color.blue : Color.gray) + .foregroundColor(.white) + .cornerRadius(12) + } + .disabled(currentPage == pages.count && !canConnect) + } + .padding(.horizontal, 24) + } + .padding(.vertical, 24) + .background(Color(.systemBackground)) + } + + // MARK: - Helper Properties + var canConnect: Bool { + currentPage < pages.count || + (!serverURL.isEmpty && !apiKey.isEmpty && !isConnecting) + } + + // MARK: - Actions + func connect() { + guard !serverURL.isEmpty, !apiKey.isEmpty else { return } + + isConnecting = true + connectionError = nil + + // Store credentials + AuthenticationManager.shared.serverURL = serverURL + AuthenticationManager.shared.apiKey = apiKey + + // Test connection + AuthenticationManager.shared.testConnection { success, error in + DispatchQueue.main.async { + isConnecting = false + + if success { + // Save credentials + if AuthenticationManager.shared.saveCredentials() { + HapticManager.notification(.success) + + // Mark onboarding as complete + UserDefaults.standard.set(true, forKey: "hasCompletedOnboarding") + + withAnimation { + isPresented = false + } + } else { + connectionError = "Failed to save credentials" + HapticManager.notification(.error) + } + } else { + connectionError = error ?? "Failed to connect to server" + HapticManager.notification(.error) + } + } + } + } +} + +// MARK: - Onboarding Page Model +struct OnboardingPage { + let title: String + let subtitle: String + let icon: String + let color: Color +} + +// MARK: - Feature Highlight View (for What's New) +struct FeatureHighlightView: View { + @Environment(\.dismiss) var dismiss + let features: [Feature] + + var body: some View { + NavigationView { + ScrollView { + VStack(spacing: 24) { + // Header + VStack(spacing: 12) { + Image(systemName: "sparkles") + .font(.system(size: 48)) + .foregroundColor(.blue) + + Text("What's New") + .font(.largeTitle) + .fontWeight(.bold) + + Text("in SLURM Manager") + .font(.title3) + .foregroundColor(.secondary) + } + .padding(.top, 20) + + // Features + VStack(spacing: 20) { + ForEach(features) { feature in + FeatureRow(feature: feature) + } + } + .padding(.horizontal) + + Spacer(minLength: 40) + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + .fontWeight(.semibold) + } + } + } + } +} + +struct Feature: Identifiable { + let id = UUID() + let icon: String + let title: String + let description: String + let color: Color +} + +struct FeatureRow: View { + let feature: Feature + + var body: some View { + HStack(alignment: .top, spacing: 16) { + ZStack { + Circle() + .fill(feature.color.opacity(0.15)) + .frame(width: 50, height: 50) + + Image(systemName: feature.icon) + .font(.title3) + .foregroundColor(feature.color) + } + + VStack(alignment: .leading, spacing: 4) { + Text(feature.title) + .font(.headline) + + Text(feature.description) + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding() + .background(Color(.secondarySystemGroupedBackground)) + .cornerRadius(12) + } +} + +// MARK: - Preview +#Preview("Onboarding") { + OnboardingView(isPresented: .constant(true)) +} + +#Preview("What's New") { + FeatureHighlightView(features: [ + Feature( + icon: "hand.draw.fill", + title: "Swipe Actions", + description: "Swipe on jobs to quickly cancel, refresh, or view output", + color: .blue + ), + Feature( + icon: "bolt.fill", + title: "Faster Loading", + description: "Improved caching for instant job list display", + color: .orange + ), + Feature( + icon: "waveform.path.ecg", + title: "Live Output", + description: "Watch job output in real-time as it runs", + color: .green + ) + ]) +} diff --git a/ios-app/SlurmManager/SlurmManager/Views/SettingsView.swift b/ios-app/SlurmManager/SlurmManager/Views/SettingsView.swift index 9717ec9..3093e2f 100644 --- a/ios-app/SlurmManager/SlurmManager/Views/SettingsView.swift +++ b/ios-app/SlurmManager/SlurmManager/Views/SettingsView.swift @@ -1,82 +1,614 @@ import SwiftUI +import Combine +// MARK: - Enhanced Settings View struct SettingsView: View { - @EnvironmentObject var authManager: AuthenticationManager + @StateObject private var authManager = AuthenticationManager.shared + @StateObject private var appState = AppState.shared @State private var showingLogoutAlert = false - + @State private var showingClearCacheAlert = false + @State private var showingServerConfig = false + @State private var showingDiagnostics = false + @State private var cacheStats: CacheStats? + var body: some View { NavigationView { - Form { - Section("Connection") { + List { + // Server Connection + serverSection + + // Security + securitySection + + // Real-time Updates + realtimeSection + + // Cache & Storage + cacheSection + + // Notifications + notificationSection + + // Appearance + appearanceSection + + // About + aboutSection + + // Sign Out + signOutSection + } + .listStyle(.insetGrouped) + .navigationTitle("Settings") + .refreshable { + await loadCacheStats() + } + .task { + await loadCacheStats() + } + .alert("Sign Out", isPresented: $showingLogoutAlert) { + Button("Cancel", role: .cancel) {} + Button("Sign Out", role: .destructive) { + authManager.logout() + } + } message: { + Text("Are you sure you want to sign out? You'll need to enter your credentials again.") + } + .alert("Clear Cache", isPresented: $showingClearCacheAlert) { + Button("Cancel", role: .cancel) {} + Button("Clear", role: .destructive) { + Task { + await CacheManager.shared.clearAll() + await loadCacheStats() + HapticManager.notification(.success) + ToastManager.shared.show("Cache cleared", type: .success) + } + } + } message: { + Text("This will clear all cached job data. Fresh data will be loaded from the server.") + } + .sheet(isPresented: $showingServerConfig) { + ServerConfigSheet() + } + .sheet(isPresented: $showingDiagnostics) { + DiagnosticsView() + } + } + } + + // MARK: - Server Section + var serverSection: some View { + Section { + // Server URL + HStack { + Label("Server", systemImage: "globe") + Spacer() + Text(truncatedURL) + .foregroundColor(.secondary) + .font(.caption) + .lineLimit(1) + } + + // Connection status + HStack { + Label("Status", systemImage: connectionIcon) + Spacer() + HStack(spacing: 6) { + Circle() + .fill(connectionColor) + .frame(width: 8, height: 8) + Text(connectionStatus) + .foregroundColor(.secondary) + .font(.caption) + } + } + + // Configure button + Button(action: { showingServerConfig = true }) { + Label("Configure Server", systemImage: "gearshape") + } + + // Test connection + Button(action: testConnection) { + Label("Test Connection", systemImage: "arrow.triangle.2.circlepath") + } + } header: { + Text("Connection") + } + } + + // MARK: - Security Section + var securitySection: some View { + Section { + Toggle(isOn: Binding( + get: { authManager.requiresBiometric }, + set: { authManager.toggleBiometric($0) } + )) { + Label("Require Face ID", systemImage: "faceid") + } + + HStack { + Label("API Key", systemImage: "key.fill") + Spacer() + Text(maskedApiKey) + .foregroundColor(.secondary) + .font(.caption) + } + } header: { + Text("Security") + } footer: { + Text("When enabled, Face ID or Touch ID is required to access the app.") + } + } + + // MARK: - Realtime Section + var realtimeSection: some View { + Section { + HStack { + Label("WebSocket", systemImage: "antenna.radiowaves.left.and.right") + Spacer() + HStack(spacing: 6) { + if case .connecting = appState.connectionStatus { + ProgressView() + .scaleEffect(0.7) + } + Text(appState.connectionStatus.displayText) + .foregroundColor(.secondary) + .font(.caption) + } + } + + Button(action: reconnectWebSocket) { + Label("Reconnect", systemImage: "arrow.clockwise") + } + } header: { + Text("Real-time Updates") + } footer: { + Text("WebSocket provides instant job status updates without polling.") + } + } + + // MARK: - Cache Section + var cacheSection: some View { + Section { + if let stats = cacheStats { + HStack { + Label("Cached Jobs", systemImage: "doc.on.doc") + Spacer() + Text("\(stats.jobCount)") + .foregroundColor(.secondary) + } + + HStack { + Label("Cache Size", systemImage: "externaldrive") + Spacer() + Text(stats.formattedSize) + .foregroundColor(.secondary) + } + + if let lastSync = stats.lastSync { HStack { - Text("Server") + Label("Last Sync", systemImage: "clock") Spacer() - Text(authManager.serverURL) + Text(formattedTime(lastSync)) .foregroundColor(.secondary) - .font(.caption) } - - HStack { - Text("Status") - Spacer() - if authManager.isAuthenticated { - Label("Connected", systemImage: "checkmark.circle.fill") - .foregroundColor(.green) - .font(.caption) - } else { - Label("Disconnected", systemImage: "xmark.circle.fill") - .foregroundColor(.red) + } + } + + Button(action: { showingClearCacheAlert = true }) { + Label("Clear Cache", systemImage: "trash") + .foregroundColor(.red) + } + } header: { + Text("Cache & Storage") + } + } + + // MARK: - Notification Section + var notificationSection: some View { + Section { + NavigationLink { + NotificationSettingsView() + } label: { + Label("Notification Settings", systemImage: "bell.badge") + } + } header: { + Text("Notifications") + } + } + + // MARK: - Appearance Section + var appearanceSection: some View { + Section { + NavigationLink { + AppearanceSettingsView() + } label: { + Label("Appearance", systemImage: "paintbrush") + } + } header: { + Text("Display") + } + } + + // MARK: - About Section + var aboutSection: some View { + Section { + HStack { + Label("Version", systemImage: "info.circle") + Spacer() + Text(appVersion) + .foregroundColor(.secondary) + } + + HStack { + Label("Build", systemImage: "hammer") + Spacer() + Text(buildNumber) + .foregroundColor(.secondary) + } + + Link(destination: URL(string: "https://github.com/ssync/ssync")!) { + Label("Documentation", systemImage: "book") + } + + Link(destination: URL(string: "https://github.com/ssync/ssync/issues")!) { + Label("Report Issue", systemImage: "exclamationmark.bubble") + } + + Button(action: { showingDiagnostics = true }) { + Label("Diagnostics", systemImage: "wrench.and.screwdriver") + } + } header: { + Text("About") + } + } + + // MARK: - Sign Out Section + var signOutSection: some View { + Section { + Button(action: { showingLogoutAlert = true }) { + HStack { + Spacer() + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + .foregroundColor(.red) + Spacer() + } + } + } + } + + // MARK: - Helper Properties + var truncatedURL: String { + let url = authManager.serverURL + if url.count > 30 { + return String(url.prefix(27)) + "..." + } + return url + } + + var connectionIcon: String { + authManager.isAuthenticated ? "checkmark.shield.fill" : "xmark.shield.fill" + } + + var connectionColor: Color { + authManager.isAuthenticated ? .green : .red + } + + var connectionStatus: String { + authManager.isAuthenticated ? "Authenticated" : "Not Connected" + } + + var maskedApiKey: String { + let key = authManager.apiKey + if key.count > 8 { + return String(key.prefix(4)) + "••••" + String(key.suffix(4)) + } + return "••••••••" + } + + var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" + } + + var buildNumber: String { + Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" + } + + // MARK: - Actions + func testConnection() { + HapticManager.impact(.medium) + authManager.testConnection { success, error in + if success { + HapticManager.notification(.success) + ToastManager.shared.show("Connection successful", type: .success) + } else { + HapticManager.notification(.error) + ToastManager.shared.show(error ?? "Connection failed", type: .error) + } + } + } + + func reconnectWebSocket() { + HapticManager.impact(.medium) + WebSocketManager.shared.reconnect() + } + + func loadCacheStats() async { + let stats = await CacheManager.shared.getCacheStats() + cacheStats = CacheStats( + jobCount: stats.jobs, + hostCount: stats.hosts, + lastSync: stats.lastSync, + sizeBytes: 0 // Would need to calculate actual size + ) + } + + func formattedTime(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } +} + +// MARK: - Cache Stats +struct CacheStats { + let jobCount: Int + let hostCount: Int + let lastSync: Date? + let sizeBytes: Int64 + + var formattedSize: String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: sizeBytes) + } +} + +// MARK: - Server Config Sheet +struct ServerConfigSheet: View { + @StateObject private var authManager = AuthenticationManager.shared + @Environment(\.dismiss) var dismiss + @State private var serverURL: String = "" + @State private var apiKey: String = "" + @State private var isTesting = false + @State private var testResult: String? + + var body: some View { + NavigationView { + Form { + Section { + TextField("Server URL", text: $serverURL) + .autocapitalization(.none) + .keyboardType(.URL) + .disableAutocorrection(true) + } header: { + Text("Server") + } footer: { + Text("e.g., https://your-server.com:8042") + } + + Section { + SecureField("API Key", text: $apiKey) + .autocapitalization(.none) + .disableAutocorrection(true) + } header: { + Text("Authentication") + } + + if let result = testResult { + Section { + HStack { + Image(systemName: result.contains("success") ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundColor(result.contains("success") ? .green : .red) + Text(result) .font(.caption) } } } - - Section("Security") { - Toggle("Require Face ID/Touch ID", isOn: $authManager.requiresBiometric) - .onChange(of: authManager.requiresBiometric) { newValue in - authManager.toggleBiometric(newValue) + + Section { + Button(action: testConnection) { + HStack { + if isTesting { + ProgressView() + .scaleEffect(0.8) + } + Text("Test Connection") } - } - - Section("WebSocket") { - HStack { - Text("Real-time Updates") - Spacer() - Text(WebSocketManager.shared.connectionStatus) - .foregroundColor(.secondary) - .font(.caption) } + .disabled(serverURL.isEmpty || apiKey.isEmpty || isTesting) } - - Section("About") { - HStack { - Text("Version") - Spacer() - Text("1.0.0") - .foregroundColor(.secondary) + } + .navigationTitle("Server Configuration") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + save() } - - Link("Documentation", destination: URL(string: "https://github.com/yourusername/slurm-manager")!) - Link("Report Issue", destination: URL(string: "https://github.com/yourusername/slurm-manager/issues")!) + .fontWeight(.semibold) + .disabled(serverURL.isEmpty || apiKey.isEmpty) } - - Section { - Button(action: { showingLogoutAlert = true }) { - Text("Sign Out") - .foregroundColor(.red) - .frame(maxWidth: .infinity) + } + .onAppear { + serverURL = authManager.serverURL + apiKey = authManager.apiKey + } + } + } + + func testConnection() { + isTesting = true + testResult = nil + + // Temporarily update credentials for testing + let originalURL = authManager.serverURL + let originalKey = authManager.apiKey + authManager.serverURL = serverURL + authManager.apiKey = apiKey + + authManager.testConnection { success, error in + isTesting = false + if success { + testResult = "Connection successful!" + } else { + testResult = error ?? "Connection failed" + } + + // Restore original if test fails + if !success { + authManager.serverURL = originalURL + authManager.apiKey = originalKey + } + } + } + + func save() { + authManager.serverURL = serverURL + authManager.apiKey = apiKey + _ = authManager.saveCredentials() + dismiss() + } +} + +// MARK: - Notification Settings View +struct NotificationSettingsView: View { + @AppStorage("notifyJobComplete") private var notifyJobComplete = true + @AppStorage("notifyJobFailed") private var notifyJobFailed = true + @AppStorage("notifyJobStarted") private var notifyJobStarted = false + + var body: some View { + Form { + Section { + Toggle("Job Completed", isOn: $notifyJobComplete) + Toggle("Job Failed", isOn: $notifyJobFailed) + Toggle("Job Started", isOn: $notifyJobStarted) + } header: { + Text("Job Events") + } footer: { + Text("Choose which events trigger notifications") + } + + Section { + Button("Open System Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) } } + } footer: { + Text("To enable or disable notifications entirely, use system settings.") } - .navigationTitle("Settings") - .alert("Sign Out", isPresented: $showingLogoutAlert) { - Button("Cancel", role: .cancel) { } - Button("Sign Out", role: .destructive) { - authManager.logout() + } + .navigationTitle("Notifications") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Appearance Settings View +struct AppearanceSettingsView: View { + @AppStorage("preferredColorScheme") private var preferredColorScheme = 0 + @AppStorage("compactJobList") private var compactJobList = false + + var body: some View { + Form { + Section { + Picker("Theme", selection: $preferredColorScheme) { + Text("System").tag(0) + Text("Light").tag(1) + Text("Dark").tag(2) } - } message: { - Text("Are you sure you want to sign out?") + } header: { + Text("Theme") + } + + Section { + Toggle("Compact Job List", isOn: $compactJobList) + } header: { + Text("Layout") + } footer: { + Text("Show more jobs per screen with a compact layout") + } + } + .navigationTitle("Appearance") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Diagnostics View +struct DiagnosticsView: View { + @Environment(\.dismiss) var dismiss + @StateObject private var appState = AppState.shared + @State private var diagnosticText = "" + + var body: some View { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(diagnosticText) + .font(.system(.caption, design: .monospaced)) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + } + .padding() + } + .navigationTitle("Diagnostics") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Done") { dismiss() } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: copyDiagnostics) { + Label("Copy", systemImage: "doc.on.doc") + } + } + } + .onAppear { + generateDiagnostics() } } } -} \ No newline at end of file + + func generateDiagnostics() { + let device = UIDevice.current + let bundle = Bundle.main + + diagnosticText = """ + === SLURM Manager Diagnostics === + + App Version: \(bundle.infoDictionary?["CFBundleShortVersionString"] ?? "Unknown") + Build: \(bundle.infoDictionary?["CFBundleVersion"] ?? "Unknown") + + Device: \(device.model) + iOS Version: \(device.systemVersion) + + Connection Status: \(appState.connectionStatus.displayText) + Server: \(AuthenticationManager.shared.serverURL) + Authenticated: \(AuthenticationManager.shared.isAuthenticated) + + WebSocket: \(WebSocketManager.shared.isConnected ? "Connected" : "Disconnected") + + Biometrics Enabled: \(AuthenticationManager.shared.requiresBiometric) + + Generated: \(Date()) + """ + } + + func copyDiagnostics() { + UIPasteboard.general.string = diagnosticText + HapticManager.notification(.success) + ToastManager.shared.show("Copied to clipboard", type: .success) + } +} + +// MARK: - Preview +#Preview { + SettingsView() +}