diff --git a/Perspective Server/AppLogStore.swift b/Perspective Server/AppLogStore.swift new file mode 100644 index 0000000..a0a9356 --- /dev/null +++ b/Perspective Server/AppLogStore.swift @@ -0,0 +1,108 @@ +import Combine +import Foundation +import OSLog + +enum AppLogSeverity: String, CaseIterable, Sendable { + case debug + case info + case warning + case error + + var label: String { + rawValue.uppercased() + } + + var osLogType: OSLogType { + switch self { + case .debug: + return .debug + case .info: + return .info + case .warning: + return .default + case .error: + return .error + } + } +} + +struct AppLogEntry: Identifiable, Sendable { + let id = UUID() + let timestamp: Date + let severity: AppLogSeverity + let source: String + let message: String +} + +@MainActor +final class AppLogStore: ObservableObject { + static let shared = AppLogStore() + + @Published private(set) var entries: [AppLogEntry] = [] + + private let maxEntries = 500 + private let copyDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter + }() + + private init() {} + + func append(_ message: String, severity: AppLogSeverity, source: String) { + let entry = AppLogEntry(timestamp: Date(), severity: severity, source: source, message: message) + entries.append(entry) + if entries.count > maxEntries { + entries.removeFirst(entries.count - maxEntries) + } + } + + func clear() { + entries.removeAll() + } + + func exportText() -> String { + entries.map { entry in + let stamp = copyDateFormatter.string(from: entry.timestamp) + return "[\(stamp)] [\(entry.severity.label)] [\(entry.source)] \(entry.message)" + } + .joined(separator: "\n") + } +} + +enum AppLog { + private static let logger = Logger(subsystem: "com.example.PerspectiveServer", category: "AppLog") + + static func debug(_ message: String, source: String = "app") { + write(message, severity: .debug, source: source) + } + + static func info(_ message: String, source: String = "app") { + write(message, severity: .info, source: source) + } + + static func warning(_ message: String, source: String = "app") { + write(message, severity: .warning, source: source) + } + + static func error(_ message: String, source: String = "app") { + write(message, severity: .error, source: source) + } + + private static func write(_ message: String, severity: AppLogSeverity, source: String) { + switch severity { + case .debug: + logger.debug("[\(source, privacy: .public)] \(message, privacy: .public)") + case .info: + logger.info("[\(source, privacy: .public)] \(message, privacy: .public)") + case .warning: + logger.notice("[\(source, privacy: .public)] \(message, privacy: .public)") + case .error: + logger.error("[\(source, privacy: .public)] \(message, privacy: .public)") + } + + Task { @MainActor in + AppLogStore.shared.append(message, severity: severity, source: source) + } + } +} diff --git a/Perspective Server/FoundationModelsService.swift b/Perspective Server/FoundationModelsService.swift index 63b486e..012c8f5 100644 --- a/Perspective Server/FoundationModelsService.swift +++ b/Perspective Server/FoundationModelsService.swift @@ -408,10 +408,12 @@ nonisolated final class FoundationModelsService: @unchecked Sendable { // Build a context-aware prompt that fits within the model's context by summarizing older content when needed. let prompt = await prepareChatPrompt(messages: request.messages, model: request.model, temperature: request.temperature, maxTokens: request.max_tokens) logger.log("[chat] model=\(request.model, privacy: .public) messages=\(request.messages.count) promptLen=\(prompt.count)") + AppLog.info("Inference started for model \(request.model)", source: "inference") // Call into Foundation Models. let output = try await generateText(model: request.model, prompt: prompt, temperature: request.temperature, maxTokens: request.max_tokens) logger.log("[chat] outputLen=\(output.count)") + AppLog.info("Inference completed (\(output.count) chars)", source: "inference") let elapsed = ContinuousClock.now - inferenceStart let ttft = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18 @@ -509,6 +511,7 @@ nonisolated final class FoundationModelsService: @unchecked Sendable { break case .unavailable(let reason): logger.error("[fm-stream] Model unavailable: \(String(describing: reason))") + AppLog.error("Streaming unavailable: \(String(describing: reason))", source: "inference") throw NSError( domain: "FoundationModelsService", code: 1, userInfo: [NSLocalizedDescriptionKey: "Model unavailable: \(String(describing: reason))"] @@ -547,6 +550,7 @@ nonisolated final class FoundationModelsService: @unchecked Sendable { let prompt = userMessage logger.log("[fm-stream] starting stream, prompt len=\(prompt.count), cached=\(isExistingSession)") + AppLog.info("Streaming inference started (cached session: \(isExistingSession))", source: "inference") do { let stream = session.streamResponse(to: prompt) @@ -580,6 +584,7 @@ nonisolated final class FoundationModelsService: @unchecked Sendable { if isSoftRefusal { // Evict the poisoned session so the next message gets a fresh one logger.warning("[fm-stream] Soft refusal detected for session \(sessionID, privacy: .public) — evicting to prevent refusal spiral") + AppLog.warning("Soft refusal detected; session reset", source: "guardrail") await sessionManager.store(sessionID, session: LanguageModelSession(instructions: instructions)) } else { // Cache the healthy session for reuse @@ -588,6 +593,7 @@ nonisolated final class FoundationModelsService: @unchecked Sendable { let cachedCount = await sessionManager.count logger.log("[fm-stream] stream complete, total len=\(lastContent.count), refusal=\(isSoftRefusal), session=\(sessionID, privacy: .public), cached sessions=\(cachedCount)") + AppLog.info("Streaming inference complete (\(lastContent.count) chars)", source: "inference") } catch { // Handle ALL errors gracefully to prevent the "Unable to stream" fallback. // Always evict the session on error — a poisoned transcript causes refusal spirals. @@ -596,8 +602,10 @@ nonisolated final class FoundationModelsService: @unchecked Sendable { if isGuardrail { logger.warning("[fm-stream] Guardrail/refusal for session \(sessionID, privacy: .public) — evicting session: \(errorDesc.prefix(120), privacy: .public)") + AppLog.warning("Guardrail/refusal triggered during streaming; returned friendly fallback", source: "guardrail") } else { logger.error("[fm-stream] Stream error for session \(sessionID, privacy: .public) — evicting session: \(errorDesc.prefix(200), privacy: .public)") + AppLog.error("Streaming inference error: \(String(errorDesc.prefix(160)))", source: "inference") } // Always evict — any error during streaming may have corrupted the session transcript @@ -1122,6 +1130,7 @@ nonisolated final class FoundationModelsService: @unchecked Sendable { private func generateText(model: String, prompt: String, temperature: Double?, maxTokens: Int?) async throws -> String { // Prefer Apple Intelligence on supported platforms; otherwise return a graceful fallback logger.log("Generating text (FoundationModels if available, else fallback)") + AppLog.debug("Generating text request received for model \(model)", source: "inference") #if canImport(FoundationModels) logger.log("[fm] FoundationModels framework is available at compile time") @@ -1131,13 +1140,16 @@ nonisolated final class FoundationModelsService: @unchecked Sendable { return try await generateWithFoundationModels(model: model, prompt: prompt, temperature: temperature) } catch { logger.error("FoundationModels failed: \(String(describing: error))") + AppLog.error("FoundationModels failed, using fallback response: \(error.localizedDescription)", source: "fallback") // Fall through to fallback message below without truncating the prompt } } else { logger.warning("[fm] Runtime availability check FAILED - macOS 26.0+ required. Current OS version does not meet requirements.") + AppLog.warning("FoundationModels runtime unavailable; using fallback response", source: "fallback") } #else logger.warning("[fm] FoundationModels framework NOT available at compile time") + AppLog.warning("FoundationModels not compiled in; using fallback response", source: "fallback") #endif // Fallback path when FoundationModels is not available on this platform/SDK. @@ -1177,11 +1189,13 @@ nonisolated final class FoundationModelsService: @unchecked Sendable { do { let response = try await session.respond(to: prompt) logger.log("[fm] got response len=\(response.content.count)") + AppLog.info("Non-stream inference completed (\(response.content.count) chars)", source: "inference") return response.content } catch { let errorDesc = String(reflecting: error).lowercased() if errorDesc.contains("guardrailviolation") || errorDesc.contains("refusal") { logger.warning("[fm] Guardrail/refusal hit — returning friendly message") + AppLog.warning("Guardrail/refusal triggered; returned friendly fallback", source: "guardrail") return "I'm not able to help with that particular request. Could you try rephrasing or asking something different?" } throw error @@ -1535,4 +1549,3 @@ nonisolated private final class ToolsRegistry: @unchecked Sendable { return nil } } - diff --git a/Perspective Server/LocalHTTPServer.swift b/Perspective Server/LocalHTTPServer.swift index b220f31..339550e 100644 --- a/Perspective Server/LocalHTTPServer.swift +++ b/Perspective Server/LocalHTTPServer.swift @@ -50,6 +50,7 @@ actor LocalHTTPServer { private func generatePairingCode() { pairingCode = String(format: "%06d", Int.random(in: 0...999999)) logger.log("Pairing code generated: \(self.pairingCode, privacy: .public)") + AppLog.info("Generated new pairing code", source: "server") } private init() {} @@ -108,6 +109,7 @@ actor LocalHTTPServer { func start() async { guard !isRunning else { logger.log("Server already running, ignoring start request") + AppLog.warning("Start requested while already running", source: "server") return } lastError = nil @@ -143,9 +145,11 @@ actor LocalHTTPServer { } listener?.start(queue: DispatchQueue.global()) logger.log("Server starting on port \(targetPort)...") + AppLog.info("Attempting to bind localhost:\(targetPort)", source: "server") } catch { lastError = "Failed to create listener on port \(targetPort): \(error.localizedDescription)" logger.error("Failed to start listener: \(String(describing: error))") + AppLog.error("Failed to create listener on port \(targetPort): \(error.localizedDescription)", source: "server") // Try next port currentPortIndex += 1 await tryStartOnNextPort() @@ -153,6 +157,7 @@ actor LocalHTTPServer { } func stop() async { + AppLog.info("Shutting down listener and active connections", source: "server") listener?.cancel() listener = nil connections.forEach { $0.cancel() } @@ -181,6 +186,7 @@ actor LocalHTTPServer { let validatedCorsOrigin = Self.isAllowedOrigin(origin) ? (origin ?? "") : "" if !Self.isAllowedHost(host, serverPort: serverPort) { logger.warning("[req:\(rid, privacy: .public)] Blocked: invalid Host header '\(host ?? "nil", privacy: .public)'") + AppLog.warning("Blocked request with invalid Host header (\(host ?? "nil"))", source: "auth") let msg = ["error": ["message": "Forbidden: invalid Host header"]] let data = (try? JSONSerialization.data(withJSONObject: msg, options: [])) ?? Data() return .normal(HTTPResponse(status: 403, headers: Self.jsonHeaders(corsOrigin: validatedCorsOrigin), body: data)) @@ -189,6 +195,7 @@ actor LocalHTTPServer { // 2. Validate Origin header (cross-origin protection) if !Self.isAllowedOrigin(origin) { logger.warning("[req:\(rid, privacy: .public)] Blocked: disallowed Origin '\(origin ?? "nil", privacy: .public)'") + AppLog.warning("Blocked request with disallowed Origin (\(origin ?? "nil"))", source: "auth") let msg = ["error": ["message": "Forbidden: origin not allowed"]] let data = (try? JSONSerialization.data(withJSONObject: msg, options: [])) ?? Data() return .normal(HTTPResponse(status: 403, headers: Self.jsonHeaders(), body: data)) @@ -242,6 +249,7 @@ actor LocalHTTPServer { let data = (try? JSONSerialization.data(withJSONObject: obj, options: [])) ?? Data() return .normal(HTTPResponse(status: 200, headers: Self.jsonHeaders(corsOrigin: corsOrigin), body: data)) } else { + AppLog.warning("Pairing verification failed: invalid code", source: "auth") let msg = ["error": ["message": "Invalid pairing code"]] let data = (try? JSONSerialization.data(withJSONObject: msg, options: [])) ?? Data() return .normal(HTTPResponse(status: 403, headers: Self.jsonHeaders(corsOrigin: corsOrigin), body: data)) @@ -259,9 +267,10 @@ actor LocalHTTPServer { }() // Basic request logging for troubleshooting - let contentType = request.headers["content-type"] ?? request.headers["Content-Type"] ?? "" - let contentLength = request.headers["content-length"] ?? request.headers["Content-Length"] ?? "" + let contentType = request.headers["content-type"] ?? request.headers["Content-Type"] ?? "" + let contentLength = request.headers["content-length"] ?? request.headers["Content-Length"] ?? "" logger.log("[req:\(rid, privacy: .public)] HTTP \(request.method, privacy: .public) \(path, privacy: .public) ct=\(contentType, privacy: .public) cl=\(contentLength, privacy: .public)") + AppLog.debug("HTTP \(request.method) \(path)", source: "request") if request.method == "POST" { logger.log("[req:\(rid, privacy: .public)] body: \(Self.truncateBodyForLog(request.bodyData), privacy: .public)") } @@ -757,6 +766,7 @@ actor LocalHTTPServer { } } catch { logger.error("[req:\(rid, privacy: .public)] /v1/chat/completions error: \(String(describing: error), privacy: .public) body=\(Self.truncateBodyForLog(request.bodyData), privacy: .public)") + AppLog.error("Inference request failed at /v1/chat/completions: \(error.localizedDescription)", source: "inference") let msg = ["error": ["message": error.localizedDescription]] let data = try? JSONSerialization.data(withJSONObject: msg, options: []) return .normal(HTTPResponse(status: 400, headers: [ @@ -767,6 +777,7 @@ actor LocalHTTPServer { } // Not Found logger.error("[req:\(rid, privacy: .public)] 404 Not Found \(path, privacy: .public)") + AppLog.warning("Request returned 404 for path \(path)", source: "request") let body = Data("Not Found".utf8) return .normal(HTTPResponse(status: 404, headers: [ "Content-Type": "text/plain", @@ -780,6 +791,7 @@ actor LocalHTTPServer { switch state { case .ready: logger.log("HTTP server listening on localhost:\(currentPort) (loopback only)") + AppLog.info("Server listening on localhost:\(currentPort)", source: "server") isRunning = true lastError = nil case .failed(let error): @@ -793,6 +805,7 @@ actor LocalHTTPServer { } logger.error("Listener failed on port \(currentPort): \(String(describing: error))") + AppLog.error("Listener failed on port \(currentPort): \(error.localizedDescription)", source: "server") listener?.cancel() listener = nil isRunning = false @@ -802,16 +815,20 @@ actor LocalHTTPServer { currentPortIndex += 1 if currentPortIndex < fallbackPorts.count { logger.log("Port \(currentPort) in use, trying next port...") + AppLog.warning("Port \(currentPort) already in use, trying fallback", source: "server") await tryStartOnNextPort() } else { lastError = "All ports in use. Tried: \(fallbackPorts.map(String.init).joined(separator: ", "))" logger.error("\(self.lastError ?? "")") + AppLog.error(lastError ?? "All ports in use", source: "server") } } else { lastError = "Server failed: \(error.localizedDescription)" + AppLog.error(lastError ?? "Server failed", source: "server") } case .cancelled: logger.log("Listener cancelled") + AppLog.info("Server listener cancelled", source: "server") isRunning = false default: break diff --git a/Perspective Server/Perspective_ServerApp.swift b/Perspective Server/Perspective_ServerApp.swift index 9a38f27..da23e1f 100644 --- a/Perspective Server/Perspective_ServerApp.swift +++ b/Perspective Server/Perspective_ServerApp.swift @@ -39,6 +39,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate, SPUStand func applicationDidFinishLaunching(_ notification: Notification) { // Disable window state restoration to prevent previously opened windows from appearing UserDefaults.standard.set(false, forKey: "NSQuitAlwaysKeepsWindows") + AppLog.info("Application launched", source: "app") } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { diff --git a/Perspective Server/RelayClient.swift b/Perspective Server/RelayClient.swift index 2a01e7d..244ad82 100644 --- a/Perspective Server/RelayClient.swift +++ b/Perspective Server/RelayClient.swift @@ -68,6 +68,7 @@ actor RelayClient { func connect() async { guard !isEnabled else { logger.log("RelayClient.connect() skipped — already enabled") + AppLog.debug("Connect skipped because relay is already enabled", source: "relay") return } isEnabled = true @@ -114,6 +115,7 @@ actor RelayClient { let delay = reconnectDelay reconnectDelay = min(reconnectDelay * 2, maxReconnectDelay) logger.log("Scheduling reconnect in \(delay, privacy: .public)s") + AppLog.warning("Relay reconnect scheduled in \(Int(delay))s", source: "relay") updateStatus(.disconnected) reconnectTask = Task { [weak self] in @@ -207,10 +209,12 @@ actor RelayClient { case "error": let msg = json["message"] as? String ?? "Unknown relay error" logger.error("Relay error: \(msg, privacy: .public)") + AppLog.error("Relay error: \(msg)", source: "relay") // If the relay token was rejected, clear it so next reconnect uses pairing code if msg.contains("relay token") { UserDefaults.standard.removeObject(forKey: relayTokenKey) logger.log("Cleared invalid relay token") + AppLog.warning("Cleared invalid relay token after auth failure", source: "relay") } updateStatus(.error(msg)) @@ -234,6 +238,7 @@ actor RelayClient { let code = await LocalHTTPServer.shared.pairingCode guard !code.isEmpty else { logger.error("No pairing code available, cannot authenticate") + AppLog.error("Relay auth failed: no pairing code available", source: "relay") updateStatus(.error("No pairing code")) return } diff --git a/Perspective Server/ServerApp.swift b/Perspective Server/ServerApp.swift index 96d05cc..233e08a 100644 --- a/Perspective Server/ServerApp.swift +++ b/Perspective Server/ServerApp.swift @@ -62,18 +62,34 @@ final class ServerController: ObservableObject { @Published var relayStatus: RelayStatus = .disconnected init() { + AppLog.info("ServerController initialized", source: "server") start() Task { await RelayClient.shared.setStatusCallback { [weak self] status in Task { @MainActor [weak self] in self?.relayStatus = status } + switch status { + case .disconnected: + AppLog.info("Relay disconnected", source: "relay") + case .connecting: + AppLog.info("Relay connecting", source: "relay") + case .waitingForAuth: + AppLog.info("Relay waiting for auth", source: "relay") + case .waitingForPairing: + AppLog.info("Relay authenticated, waiting for pairing", source: "relay") + case .paired(let userId): + AppLog.info("Relay paired with user \(userId)", source: "relay") + case .error(let message): + AppLog.error("Relay error: \(message)", source: "relay") + } } } } func start() { errorMessage = nil + AppLog.info("Starting local server on preferred port \(port)", source: "server") Task { await ServerMetrics.shared.reset() await LocalHTTPServer.shared.setPort(port) @@ -85,6 +101,11 @@ final class ServerController: ObservableObject { self.isRunning = running self.errorMessage = error self.pairingCode = code + if running { + AppLog.info("Server running on port \(self.port)", source: "server") + } else if let error { + AppLog.error("Server failed to start: \(error)", source: "server") + } if running && self.relayEnabled { await RelayClient.shared.connect() } @@ -92,17 +113,20 @@ final class ServerController: ObservableObject { } func stop() { + AppLog.info("Stopping local server", source: "server") Task { await RelayClient.shared.disconnect() await LocalHTTPServer.shared.stop() let running = await LocalHTTPServer.shared.getIsRunning() self.isRunning = running self.errorMessage = nil + AppLog.info("Server stopped", source: "server") } } func restart() { errorMessage = nil + AppLog.info("Restarting local server on preferred port \(port)", source: "server") Task { await RelayClient.shared.disconnect() await LocalHTTPServer.shared.stop() @@ -115,6 +139,11 @@ final class ServerController: ObservableObject { self.isRunning = running self.errorMessage = error self.pairingCode = code + if running { + AppLog.info("Server restarted on port \(self.port)", source: "server") + } else if let error { + AppLog.error("Server restart failed: \(error)", source: "server") + } if running && relayEnabled { await RelayClient.shared.connect() } @@ -139,6 +168,7 @@ final class ServerController: ObservableObject { func setRelayEnabled(_ enabled: Bool) { relayEnabled = enabled UserDefaults.standard.set(enabled, forKey: "relayEnabled") + AppLog.info("Relay \(enabled ? "enabled" : "disabled")", source: "relay") Task { if enabled && isRunning { await RelayClient.shared.connect() diff --git a/Perspective Server/ServerDashboardView.swift b/Perspective Server/ServerDashboardView.swift index 0ce7493..6b5f451 100644 --- a/Perspective Server/ServerDashboardView.swift +++ b/Perspective Server/ServerDashboardView.swift @@ -11,12 +11,13 @@ struct ServerDashboardView: View { @EnvironmentObject private var serverController: ServerController @Environment(\.openWindow) private var openWindow @Environment(\.colorScheme) private var colorScheme + @ObservedObject private var appLogStore = AppLogStore.shared @State private var localPort: String = "11434" @State private var showCopiedToast: Bool = false @State private var copiedText: String = "" @State private var testResult: String = "" @State private var isTesting: Bool = false - @State private var logMessages: [LogMessage] = [] + @State private var autoScrollLogs: Bool = true @State private var autoStart: Bool = true @State private var metrics: MetricsSnapshot = MetricsSnapshot( totalRequests: 0, totalInferenceRequests: 0, @@ -28,6 +29,11 @@ struct ServerDashboardView: View { // Native system colors private let successColor = Color.green private let errorColor = Color.red + private static let logTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter + }() var body: some View { ZStack { @@ -61,6 +67,9 @@ struct ServerDashboardView: View { // Connection Test Card testConnectionCard + + // Logs Card + logsCard Spacer(minLength: 20) } @@ -84,7 +93,7 @@ struct ServerDashboardView: View { refreshMetrics() metricsTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in Task { @MainActor in - await refreshMetrics() + refreshMetrics() } } } @@ -278,13 +287,13 @@ struct ServerDashboardView: View { Button(action: { if serverController.isRunning { serverController.stop() - addLog("Server stopped", type: .info) + AppLog.info("Server stop requested from dashboard", source: "dashboard") } else { if let portNum = UInt16(localPort) { serverController.port = portNum } serverController.start() - addLog("Server started on port \(serverController.port)", type: .success) + AppLog.info("Server start requested from dashboard (port \(serverController.port))", source: "dashboard") } }) { VStack(spacing: 8) { @@ -507,10 +516,10 @@ struct ServerDashboardView: View { serverController.port = portNum if serverController.isRunning { serverController.restart() - addLog("Server restarted on port \(portNum)", type: .info) + AppLog.info("Server restart requested from dashboard (port \(portNum))", source: "dashboard") } else { serverController.start() - addLog("Server started on port \(portNum)", type: .success) + AppLog.info("Server start requested from dashboard (port \(portNum))", source: "dashboard") } } }) { @@ -1027,6 +1036,114 @@ struct ServerDashboardView: View { return "\(n)" } + // MARK: - Logs Card + + private var logsCard: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Label("Logs", systemImage: "text.alignleft") + .font(.headline) + .foregroundColor(.primary) + Spacer() + Toggle("Auto-scroll", isOn: $autoScrollLogs) + .toggleStyle(.switch) + .font(.caption) + .labelsHidden() + .help("Automatically scroll to newest log entries") + Button("Copy") { + copyToClipboard(appLogStore.exportText(), message: "Logs copied") + } + .buttonStyle(.bordered) + .disabled(appLogStore.entries.isEmpty) + Button("Clear") { + appLogStore.clear() + AppLog.info("Logs cleared from dashboard", source: "dashboard") + } + .buttonStyle(.bordered) + .disabled(appLogStore.entries.isEmpty) + } + + Divider() + + ScrollViewReader { proxy in + ScrollView { + if appLogStore.entries.isEmpty { + Text("No log entries yet.") + .font(.subheadline) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, minHeight: 120, alignment: .center) + } else { + LazyVStack(alignment: .leading, spacing: 6) { + ForEach(appLogStore.entries) { entry in + logRow(entry) + .id(entry.id) + } + } + .textSelection(.enabled) + .padding(.vertical, 2) + } + } + .frame(minHeight: 150, maxHeight: 220) + .padding(10) + .background(Color(NSColor.textBackgroundColor)) + .cornerRadius(10) + .onAppear { + scrollLogsToBottom(proxy) + } + .onChange(of: appLogStore.entries.count) { _, _ in + scrollLogsToBottom(proxy) + } + } + } + .padding(20) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(16) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color(NSColor.separatorColor), lineWidth: 1) + ) + } + + private func logRow(_ entry: AppLogEntry) -> some View { + HStack(alignment: .top, spacing: 8) { + Text(Self.logTimeFormatter.string(from: entry.timestamp)) + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundColor(.secondary) + .frame(width: 58, alignment: .leading) + + Text(entry.severity.label) + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundColor(severityColor(entry.severity)) + .frame(width: 52, alignment: .leading) + + Text("[\(entry.source)] \(entry.message)") + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 1) + } + + private func severityColor(_ severity: AppLogSeverity) -> Color { + switch severity { + case .debug: + return .secondary + case .info: + return .blue + case .warning: + return .orange + case .error: + return errorColor + } + } + + private func scrollLogsToBottom(_ proxy: ScrollViewProxy) { + guard autoScrollLogs, let lastID = appLogStore.entries.last?.id else { return } + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo(lastID, anchor: .bottom) + } + } + @MainActor private func refreshMetrics() { Task { @@ -1082,7 +1199,7 @@ struct ServerDashboardView: View { testResult = "Success! Models found:\n• " + modelNames.joined(separator: "\n• ") } isTesting = false - addLog("Connection test passed", type: .success) + AppLog.info("Connection test passed", source: "dashboard") } } else { await MainActor.run { @@ -1094,7 +1211,7 @@ struct ServerDashboardView: View { await MainActor.run { testResult = "Server returned status \(httpResponse.statusCode)" isTesting = false - addLog("Connection test failed: status \(httpResponse.statusCode)", type: .error) + AppLog.error("Connection test failed: status \(httpResponse.statusCode)", source: "dashboard") } } } @@ -1102,7 +1219,7 @@ struct ServerDashboardView: View { await MainActor.run { testResult = "Connection failed:\n\(error.localizedDescription)" isTesting = false - addLog("Connection test failed: \(error.localizedDescription)", type: .error) + AppLog.error("Connection test failed: \(error.localizedDescription)", source: "dashboard") } } } @@ -1134,26 +1251,6 @@ struct ServerDashboardView: View { return lines.joined(separator: "\n") } - private func addLog(_ message: String, type: LogType) { - let log = LogMessage(message: message, type: type, timestamp: Date()) - logMessages.insert(log, at: 0) - if logMessages.count > 50 { - logMessages.removeLast() - } - } -} - -// MARK: - Supporting Types - -struct LogMessage: Identifiable { - let id = UUID() - let message: String - let type: LogType - let timestamp: Date -} - -enum LogType { - case info, success, error } #Preview {