git.iko.soy/parseable-macos
on master help sign in
Add Parseable Viewer macOS application (#1)
b032d01iko
.github/workflows/build.ymlnew file+158
@@ -0,0 +1,158 @@
1+name: Build and Release
2+
3+on:
4+ push:
5+ branches: [master, main]
6+ tags:
7+ - 'v*'
8+ pull_request:
9+ branches: [master, main]
10+
11+jobs:
12+ build:
13+ runs-on: macos-15
14+ strategy:
15+ matrix:
16+ include:
17+ - arch: arm64
18+ sdk: macosx
19+ - arch: x86_64
20+ sdk: macosx
21+
22+ steps:
23+ - name: Checkout
24+ uses: actions/checkout@v4
25+
26+ - name: Select Xcode 16
27+ run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer
28+
29+ - name: Install XcodeGen
30+ run: brew install xcodegen
31+
32+ - name: Generate Xcode project
33+ run: xcodegen generate
34+
35+ - name: Run Tests
36+ if: matrix.arch == 'arm64'
37+ run: |
38+ xcodebuild test \
39+ -project ParseableViewer.xcodeproj \
40+ -scheme ParseableViewer \
41+ -configuration Debug \
42+ -destination 'platform=macOS,arch=arm64' \
43+ -derivedDataPath build \
44+ CODE_SIGN_IDENTITY="-" \
45+ CODE_SIGNING_REQUIRED=NO \
46+ CODE_SIGNING_ALLOWED=NO
47+
48+ - name: Build
49+ run: |
50+ xcodebuild \
51+ -project ParseableViewer.xcodeproj \
52+ -scheme ParseableViewer \
53+ -configuration Release \
54+ -arch ${{ matrix.arch }} \
55+ -derivedDataPath build \
56+ CODE_SIGN_IDENTITY="-" \
57+ CODE_SIGNING_REQUIRED=NO \
58+ CODE_SIGNING_ALLOWED=NO \
59+ build
60+
61+ - name: Package app
62+ run: |
63+ APP_PATH="build/Build/Products/Release/Parseable Viewer.app"
64+ if [ -d "$APP_PATH" ]; then
65+ cd build/Build/Products/Release
66+ zip -r -y "ParseableViewer-${{ matrix.arch }}.zip" "Parseable Viewer.app"
67+ mv "ParseableViewer-${{ matrix.arch }}.zip" "$GITHUB_WORKSPACE/"
68+ else
69+ echo "App not found at expected path, searching..."
70+ find build -name "*.app" -type d
71+ exit 1
72+ fi
73+
74+ - name: Upload artifact
75+ uses: actions/upload-artifact@v4
76+ with:
77+ name: ParseableViewer-${{ matrix.arch }}
78+ path: ParseableViewer-${{ matrix.arch }}.zip
79+
80+ create-universal:
81+ needs: build
82+ runs-on: macos-15
83+ steps:
84+ - name: Checkout
85+ uses: actions/checkout@v4
86+
87+ - name: Download arm64 build
88+ uses: actions/download-artifact@v4
89+ with:
90+ name: ParseableViewer-arm64
91+ path: artifacts/arm64
92+
93+ - name: Download x86_64 build
94+ uses: actions/download-artifact@v4
95+ with:
96+ name: ParseableViewer-x86_64
97+ path: artifacts/x86_64
98+
99+ - name: Create Universal Binary
100+ run: |
101+ # Unzip both architectures
102+ mkdir -p arm64_app x86_64_app universal_app
103+ cd artifacts/arm64 && unzip ParseableViewer-arm64.zip -d ../../arm64_app && cd ../..
104+ cd artifacts/x86_64 && unzip ParseableViewer-x86_64.zip -d ../../x86_64_app && cd ../..
105+
106+ # Copy the arm64 app as base
107+ cp -R "arm64_app/Parseable Viewer.app" "universal_app/Parseable Viewer.app"
108+
109+ # Find the main executable name
110+ EXEC_NAME="Parseable Viewer"
111+ ARM64_EXEC="arm64_app/Parseable Viewer.app/Contents/MacOS/$EXEC_NAME"
112+ X86_EXEC="x86_64_app/Parseable Viewer.app/Contents/MacOS/$EXEC_NAME"
113+ UNIVERSAL_EXEC="universal_app/Parseable Viewer.app/Contents/MacOS/$EXEC_NAME"
114+
115+ if [ -f "$ARM64_EXEC" ] && [ -f "$X86_EXEC" ]; then
116+ lipo -create "$ARM64_EXEC" "$X86_EXEC" -output "$UNIVERSAL_EXEC"
117+ echo "Created universal binary"
118+ lipo -info "$UNIVERSAL_EXEC"
119+ else
120+ echo "Error: Could not find executables for universal binary"
121+ echo "arm64: $(ls -la arm64_app/Parseable\ Viewer.app/Contents/MacOS/ 2>/dev/null || echo 'not found')"
122+ echo "x86_64: $(ls -la x86_64_app/Parseable\ Viewer.app/Contents/MacOS/ 2>/dev/null || echo 'not found')"
123+ exit 1
124+ fi
125+
126+ # Create zip
127+ cd universal_app
128+ zip -r -y "../ParseableViewer-universal.zip" "Parseable Viewer.app"
129+
130+ - name: Upload universal artifact
131+ uses: actions/upload-artifact@v4
132+ with:
133+ name: ParseableViewer-universal
134+ path: ParseableViewer-universal.zip
135+
136+ release:
137+ needs: create-universal
138+ runs-on: macos-15
139+ if: startsWith(github.ref, 'refs/tags/v')
140+ permissions:
141+ contents: write
142+ steps:
143+ - name: Download all artifacts
144+ uses: actions/download-artifact@v4
145+ with:
146+ path: artifacts
147+
148+ - name: Create Draft Release
149+ uses: softprops/action-gh-release@v2
150+ with:
151+ draft: true
152+ generate_release_notes: true
153+ files: |
154+ artifacts/ParseableViewer-arm64/ParseableViewer-arm64.zip
155+ artifacts/ParseableViewer-x86_64/ParseableViewer-x86_64.zip
156+ artifacts/ParseableViewer-universal/ParseableViewer-universal.zip
157+ env:
158+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CLAUDE.mdnew file+109
@@ -0,0 +1,109 @@
1+# Parseable Viewer - macOS App
2+
3+Native macOS log viewer for [Parseable](https://www.parseable.com/) built with Swift and SwiftUI.
4+
5+## Build
6+
7+Requires macOS with Xcode 16+ and [XcodeGen](https://github.com/yonaskolb/XcodeGen).
8+
9+```bash
10+brew install xcodegen
11+xcodegen generate
12+xcodebuild -project ParseableViewer.xcodeproj -scheme ParseableViewer -configuration Release build
13+```
14+
15+Run tests:
16+
17+```bash
18+xcodebuild test \
19+ -project ParseableViewer.xcodeproj \
20+ -scheme ParseableViewer \
21+ -configuration Debug \
22+ -destination 'platform=macOS'
23+```
24+
25+CI builds on `macos-15` runners via `.github/workflows/build.yml`, producing arm64, x86_64, and universal binaries.
26+
27+## Architecture
28+
29+- **Language**: Swift 5.9, SwiftUI, macOS 14.0+ (Sonoma)
30+- **Pattern**: MVVM with `@Observable` classes and `@Environment` injection
31+- **Concurrency**: Swift structured concurrency (`async/await`, `@MainActor`, strict `Sendable` conformance)
32+- **Networking**: `URLSession` async/await, no external dependencies
33+- **Project generation**: XcodeGen from `project.yml`
34+- **Persistence**: `UserDefaults` for connections and saved queries; Keychain for passwords
35+
36+### Source layout
37+
38+```
39+ParseableViewer/
40+ App/ # @main entry point + AppState (central @Observable)
41+ Models/ # Codable data types (JSONValue, LogStream, ServerInfo, etc.)
42+ Services/ # ParseableClient (REST API), ConnectionStore (persistence), KeychainService
43+ ViewModels/ # QueryViewModel, LiveTailViewModel
44+ Views/ # All SwiftUI views
45+ Resources/ # Asset catalog
46+ParseableViewerTests/ # Unit tests for models, view models, and services
47+```
48+
49+### Key types
50+
51+- `AppState` — singleton `@Observable` injected via `.environment()`. Holds connections, streams, selected stream, navigation state. Clears stream-specific state when switching servers.
52+- `ParseableClient` — `Sendable` HTTP client for all Parseable REST API calls. Uses Basic Auth. Created per-connection. Uses `finishTasksAndInvalidate()` on deinit so in-flight requests complete gracefully.
53+- `JSONValue` — recursive `Codable`, `Hashable`, `Comparable` enum for type-safe arbitrary JSON (`null | bool | int | double | string | array | object`). Provides `displayString` (UI), `exportString` (CSV/JSON export with full nested serialization), and type-aware comparison (numeric values sort numerically, strings use `localizedStandardCompare`).
54+- `QueryViewModel` — manages SQL text, time range, query execution, result columns, filtering, CSV/JSON export, and query history. Auto-updates the default query when the user switches streams.
55+- `LiveTailViewModel` — timer-based polling that queries recent logs, deduplicates by FNV-1a content fingerprint, and appends to a capped buffer. Auto-stops after 5 consecutive poll failures.
56+
57+### Parseable API endpoints used
58+
59+All under `/api/v1/`. Auth: `Authorization: Basic <base64(user:pass)>`.
60+
61+| Method | Path | Purpose |
62+|--------|------|---------|
63+| HEAD | `/liveness` | Health check |
64+| GET | `/about` | Server version, mode, storage info |
65+| GET | `/logstream` | List all streams |
66+| PUT/DELETE | `/logstream/{name}` | Create/delete stream |
67+| GET | `/logstream/{name}/schema` | Field names and types |
68+| GET | `/logstream/{name}/stats` | Ingestion/storage statistics |
69+| GET | `/logstream/{name}/info` | Creation time, partitioning config |
70+| POST | `/query` | SQL query with time range |
71+| GET | `/alerts` | Alert rules (also tries legacy `/logstream/{name}/alert`) |
72+| GET | `/logstream/{name}/retention` | Retention policy |
73+| GET | `/user` | List users |
74+
75+The query endpoint accepts `{"query": "SQL", "startTime": "ISO8601", "endTime": "ISO8601"}` and returns either `[LogRecord]` or `{"records": [LogRecord], "fields": [...]}`.
76+
77+Live tail in Parseable uses gRPC Arrow Flight streaming. This app approximates it via HTTP polling with a sliding time window.
78+
79+## Conventions
80+
81+- No external dependencies; the app uses only Apple frameworks
82+- All model types conform to `Sendable` for strict concurrency safety
83+- Models use defensive `try?` decoding for optional fields to tolerate API version differences
84+- Views follow the macOS pattern: `NavigationSplitView` sidebar + detail, with tab switching in the detail pane
85+- All API calls are `async` and dispatched from views via `Task { }`
86+- Passwords are stored in the Keychain, never in UserDefaults; `ServerConnection.password` is excluded from `Codable`
87+- Stream names are SQL-escaped via `escapeSQLIdentifier` (double-quote wrapping) and URL-encoded via `encodePathComponent` for API paths
88+- Views clear stale data before loading new content (e.g., switching streams clears previous schema/stats)
89+- Entitlements: App Sandbox enabled with network.client for outbound connections
90+
91+## Common patterns
92+
93+### Adding a new API endpoint
94+
95+1. Add the method to `ParseableClient` using `performRequest(method:path:body:)` — it handles auth, status codes, and error mapping
96+2. Add any new response types to the appropriate model file with defensive `try?` decoding for optional fields
97+3. Call from the view layer via `Task { }` with appropriate loading/error state management
98+
99+### Adding a new detail tab
100+
101+1. Add a case to `AppState.AppTab` with a `systemImage`
102+2. Add the view to `StreamDetailView`'s tab switching logic
103+3. The sidebar tab bar auto-renders from `AppTab.allCases`
104+
105+### Error handling
106+
107+- Use `ParseableError.userFriendlyMessage(for:)` to convert any error (including `NSURLError` codes) into a human-readable string
108+- API methods throw `ParseableError` variants; views catch and display via `errorMessage` state
109+- Cancelled tasks (e.g., disconnect during auto-reconnect) are detected via `Task.isCancelled` to suppress spurious error alerts
IMPROVEMENTS.mdnew file+111
@@ -0,0 +1,111 @@
1+# Remaining Improvements
2+
3+Potential improvements for Parseable Viewer, cataloged after a thorough audit of every source file. Organized by category and severity.
4+
5+---
6+
7+## High Priority
8+
9+### Keychain access control too permissive
10+**`KeychainService.swift:15-19`** — Passwords are stored with `kSecClassGenericPassword` using only `kSecAttrService` and `kSecAttrAccount`. No access control list (ACL) or `kSecAttrAccessible` value is set, so the default accessibility applies. Adding `kSecAttrAccessibleWhenUnlocked` (or `AfterFirstUnlock`) would restrict access to when the device is unlocked, following Keychain best practices.
11+
12+### Auth header silently degrades on encoding failure
13+**`ParseableClient.swift:88-93`** — If `"\(username):\(password)".data(using: .utf8)` returns `nil`, `authHeader` returns the string `"Basic "` (no credentials). Every subsequent API call sends this empty auth header, receiving 401s with no indication that the root cause is a credential encoding issue. Should throw or log a clear error.
14+
15+### No data migration for persisted stores
16+**`ConnectionStore.swift:7-11`, `SavedQueryStore.swift:46-50`** — Both stores decode from UserDefaults with `try? JSONDecoder().decode(...)`, falling back to `[]` on failure. If the schema ever changes (e.g., a new required field on `SavedQuery` or `ServerConnection`), all stored data silently disappears. Adding a version key and migration logic would prevent data loss across app updates.
17+
18+### No auto-reconnect on network recovery
19+**`AppState.swift:77-82`** — The `NWPathMonitor` updates `isNetworkAvailable` but doesn't trigger reconnection when the network comes back. If the user was connected and the network drops then recovers, the app stays disconnected. The user must manually click Connect. A reconnect-on-recovery flow (with the last active connection) would be a significant UX improvement.
20+
21+---
22+
23+## Medium Priority
24+
25+### No keyboard navigation in log table
26+**`LogTableView.swift:53-63`** — Row selection is mouse-only via `onTapGesture`. macOS users expect arrow-key navigation through table rows. Would require either switching to `List(selection:)` (which provides this for free) or adding a custom `.onKeyPress` handler.
27+
28+### Loading skeleton / empty state ambiguity
29+**`ContentView.swift:16-24`** — After connecting, the detail pane shows "Select a stream" even while streams are still loading. There's no visual distinction between "loading streams" and "streams loaded, none selected." A loading indicator in the detail pane (checking `appState.isLoadingStreams`) would clarify the state.
30+
31+### Log level colors only — no text fallback for colorblind users
32+**`LogTableView.swift:193-199`, `LiveTailEntryRow:189-194`, `LogDetailView.swift:136-183`** — Error/warn/info levels are distinguished solely by color (red/orange/blue). Colorblind users can't differentiate them. Adding a small icon or text badge alongside the color would improve accessibility.
33+
34+### Server error messages may expose sensitive info
35+**`ParseableError.swift:31-32`** — `.serverError(code, message)` includes the raw server response body in the user-facing error. If the server returns a stack trace or internal paths, these get shown in the UI. Truncating the message or stripping known sensitive patterns would be safer.
36+
37+### Reduce-motion support for LiveTail pulse animation
38+**`LiveTailView.swift:88-95`** — The pulsing green circle animates continuously with `repeatForever`. Users with vestibular sensitivity (who enable "Reduce motion" in System Settings) still see the animation. Checking `@Environment(\.accessibilityReduceMotion)` and showing a static indicator when enabled would respect the system preference.
39+
40+### Stream deletion doesn't debounce stats fetch
41+**`SidebarView.swift:62-78`** — The "Delete..." context menu item fires an async `getStreamStats` call before showing the confirmation dialog. Rapid right-click → Delete on multiple streams triggers parallel stats fetches with no debouncing. Minor, but could be noisy on slow connections.
42+
43+### SettingsView has hard-coded frame size
44+**`SettingsView.swift:102`** — `frame(width: 500, height: 400)` doesn't adapt to user text size preferences or longer translations. Using `minWidth`/`minHeight` with flexible layout would handle more scenarios.
45+
46+### Poll error backoff is all-or-nothing
47+**`LiveTailViewModel.swift:153-162`** — The auto-stop fires after exactly 5 consecutive errors regardless of error type. A transient 503 (server restarting) is treated the same as a permanent 404 (stream deleted). Exponential backoff for retryable errors with immediate stop for permanent errors would be more nuanced.
48+
49+### URLSession has no retry logic for transient failures
50+**`ParseableClient.swift:116-134`** — `performRequest` makes a single attempt. Transient errors (timeout, connection reset) fail immediately. Adding a single automatic retry with a short delay for idempotent GET requests would improve reliability without changing semantics.
51+
52+### No results context in empty table state
53+**`LogTableView.swift:39-47`** — "No results" shows whether no query has been run or the query returned zero rows. Could distinguish with "Run a query to see results" vs "Query returned 0 rows."
54+
55+---
56+
57+## Low Priority
58+
59+### `TimeRangePicker` "Done" button validation gap
60+**`TimeRangePicker.swift:79-83`** — The "Done" button is disabled when `customEnd < customStart`, but the user can still close the popover by clicking outside it, leaving the invalid date range active. The query will fail at the API level with a confusing error.
61+
62+### Copy-to-clipboard not debounced
63+**`LogDetailView.swift:54-69`** — Rapid clicks on the copy button trigger multiple clipboard operations and the checkmark animation restarts. A simple `guard !showCopyConfirmation` would prevent redundant copies.
64+
65+### No character counter on stream name input
66+**`SidebarView.swift:155-176`** — The stream name validation enforces a 255-character limit, but the user only discovers this after clicking "Create." A live character counter in the text field would prevent surprise validation failures.
67+
68+### Query history is per-app, not per-connection
69+**`QueryViewModel.swift:300-311`** — Query history is stored in a single UserDefaults key. If the user has two servers with different schemas, history from Server A shows in the dropdown while connected to Server B. Keying history by connection ID would scope it correctly.
70+
71+### `prettyPrinted` String padding is rebuilt per line
72+**`JSONValue.swift:90-91`** — `String(repeating: " ", count: indent)` allocates a new string for every line of deeply nested JSON. For very large records this is wasteful, though in practice the cost is negligible for typical log entries.
73+
74+### `exportAsJSON` swallows encoding errors
75+**`QueryViewModel.swift:232-241`** — If `JSONEncoder().encode(results)` fails, it silently returns `"[]"` instead of surfacing the error. The caller has no way to know the export was empty due to failure vs. genuinely empty results.
76+
77+### Tab state not persisted across app launches
78+**`AppState.swift:28`** — `currentTab` defaults to `.query` on every launch. Saving the last active tab to UserDefaults would restore the user's context.
79+
80+### LiveTail filter doesn't search nested values
81+**`LiveTailViewModel.swift:52-58`** — `filteredEntries` checks `entry.summary` and `record.values` using `displayString`, which shows `"[3 items]"` for arrays. Searching for content inside nested objects won't match. Using `exportString` for filter matching would search actual content, but at a performance cost.
82+
83+### `FormattedRecordView.sortedKeys` recomputed on every render
84+**`LogDetailView.swift:75-87`** — The `sortedKeys` computed property does `firstIndex` + `remove(at:)` for each priority key on every view update. For typical records (~20 fields) this is negligible, but memoizing with the record as the key would be cleaner.
85+
86+### Saved query creation has no duplicate detection
87+**`QueryView.swift:232-244`** — The user can save the same SQL query with the same name multiple times. No check for duplicate names or SQL text before appending to the list.
88+
89+### No export progress for large result sets
90+**`QueryView.swift:273-297`** — Export runs in a `Task.detached` with no progress feedback. For 10k+ rows with nested JSON, the CSV/JSON generation could take seconds. The UI appears frozen with no indication that the export is in progress.
91+
92+---
93+
94+## Already Fixed (for reference)
95+
96+The following issues were identified and fixed during this audit:
97+
98+| Commit | Fix |
99+|--------|-----|
100+| `06299ee` | Clipboard false-success, export silent failure, orphaned connect task |
101+| `f4a6e8d` | Orphaned queries on tab switch, input validation, queue retention |
102+| `3740f3e` | Malformed API responses returning empty data instead of throwing |
103+| `1554c22` | Inconsistent error messages across views |
104+| `05a9fb4` | CSV escaping for carriage returns, clipboard warnings |
105+| `3eda714` | Stale keychain password, silent stream load failure |
106+| `56ab0c0` | False truncation warning for custom SQL, cancel-on-disconnect alert |
107+| `8b7eed0` | Silent request cancellation on deinit, CSV export data loss, queryLimit bounds |
108+| `840c7e4` | LiveTail fingerprint collisions, auto-stop on persistent errors |
109+| `8fda678` | Stale stream selection on server switch, cached date formatter |
110+| `4574ca2` | Numeric column sorting, stale query on stream switch |
111+| `b89a537` | Stale cross-stream data in detail/alerts views |
ParseableViewer/App/AppState.swiftnew file+255
@@ -0,0 +1,255 @@
1+import Foundation
2+import Network
3+import SwiftUI
4+
5+@Observable
6+final class AppState {
7+ // MARK: - Connection State
8+ var connections: [ServerConnection] = []
9+ var activeConnection: ServerConnection?
10+ var client: ParseableClient?
11+ var isConnected = false
12+ var isConnecting = false
13+ var serverAbout: ServerAbout?
14+ private(set) var isNetworkAvailable = true
15+ private let networkMonitor = NWPathMonitor()
16+ private let networkMonitorQueue = DispatchQueue(label: "com.parseableviewer.network-monitor")
17+ private var connectTask: Task<Void, Never>?
18+
19+ // MARK: - Stream State
20+ var streams: [LogStream] = []
21+ var selectedStream: String?
22+ var isLoadingStreams = false
23+ var streamLoadError: String?
24+
25+ // MARK: - Navigation
26+ var showConnectionSheet = false
27+ var editingConnection: ServerConnection?
28+ var currentTab: AppTab = .query
29+ var streamSearchText = ""
30+
31+ // MARK: - Error
32+ var errorMessage: String?
33+ var showError = false
34+
35+ // MARK: - Saved Queries
36+ var savedQueries: [SavedQuery] = []
37+ /// Set by sidebar when user clicks a saved query; consumed by QueryView.
38+ var pendingSavedQuerySQL: String?
39+
40+ enum AppTab: String, CaseIterable, Identifiable {
41+ case query = "Query"
42+ case liveTail = "Live Tail"
43+ case streamInfo = "Stream Info"
44+ case alerts = "Alerts"
45+ case users = "Users"
46+ case serverInfo = "Server Info"
47+
48+ var id: String { rawValue }
49+
50+ var systemImage: String {
51+ switch self {
52+ case .query: return "magnifyingglass"
53+ case .liveTail: return "antenna.radiowaves.left.and.right"
54+ case .streamInfo: return "info.circle"
55+ case .alerts: return "bell"
56+ case .users: return "person.2"
57+ case .serverInfo: return "server.rack"
58+ }
59+ }
60+ }
61+
62+ var filteredStreams: [LogStream] {
63+ if streamSearchText.isEmpty {
64+ return streams
65+ }
66+ return streams.filter { $0.name.localizedCaseInsensitiveContains(streamSearchText) }
67+ }
68+
69+ deinit {
70+ networkMonitor.cancel()
71+ }
72+
73+ init() {
74+ connections = ConnectionStore.loadConnections()
75+ savedQueries = SavedQueryStore.load()
76+
77+ networkMonitor.pathUpdateHandler = { [weak self] path in
78+ Task { @MainActor [weak self] in
79+ self?.isNetworkAvailable = path.status == .satisfied
80+ }
81+ }
82+ networkMonitor.start(queue: networkMonitorQueue)
83+
84+ if let activeID = ConnectionStore.loadActiveConnectionID(),
85+ let connection = connections.first(where: { $0.id == activeID }) {
86+ connectTask = Task { @MainActor in
87+ await connect(to: connection)
88+ }
89+ }
90+ }
91+
92+ // MARK: - Connection Management
93+
94+ @MainActor
95+ func connect(to connection: ServerConnection) async {
96+ guard !isConnecting else { return }
97+
98+ if !isNetworkAvailable {
99+ self.errorMessage = "No internet connection. Check your network and try again."
100+ self.showError = true
101+ return
102+ }
103+
104+ isConnecting = true
105+ errorMessage = nil
106+
107+ // When switching to a different server, clear stream-specific state
108+ // so stale selections don't reference streams that don't exist.
109+ if activeConnection?.id != connection.id {
110+ selectedStream = nil
111+ streamLoadError = nil
112+ }
113+
114+ do {
115+ let newClient = try ParseableClient(connection: connection)
116+
117+ // Test connection — throws on non-200 or network failure
118+ try await newClient.checkHealth()
119+
120+ self.client = newClient
121+ self.activeConnection = connection
122+ self.isConnected = true
123+
124+ ConnectionStore.saveActiveConnectionID(connection.id)
125+
126+ // Load server info and streams (best-effort; don't fail the connection)
127+ async let about = newClient.getAbout()
128+ async let streamList = newClient.listStreams()
129+
130+ self.serverAbout = try? await about
131+ do {
132+ self.streams = try await streamList
133+ self.streamLoadError = nil
134+ } catch {
135+ self.streams = []
136+ self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
137+ }
138+ } catch {
139+ // If the task was cancelled (e.g. user clicked Disconnect during
140+ // auto-reconnect), skip the error alert — disconnect() already
141+ // cleaned up the state.
142+ if !Task.isCancelled {
143+ self.errorMessage = ParseableError.userFriendlyMessage(for: error)
144+ self.showError = true
145+ self.isConnected = false
146+ self.client = nil
147+ self.activeConnection = nil
148+ }
149+ }
150+
151+ isConnecting = false
152+ }
153+
154+ @MainActor
155+ func disconnect() {
156+ connectTask?.cancel()
157+ connectTask = nil
158+ client = nil
159+ activeConnection = nil
160+ isConnected = false
161+ streams = []
162+ selectedStream = nil
163+ serverAbout = nil
164+ ConnectionStore.saveActiveConnectionID(nil)
165+ }
166+
167+ @MainActor
168+ func refreshStreams() async {
169+ guard let client else { return }
170+ isLoadingStreams = true
171+ streamLoadError = nil
172+ do {
173+ streams = try await client.listStreams()
174+ } catch {
175+ let message = ParseableError.userFriendlyMessage(for: error)
176+ streamLoadError = message
177+ self.errorMessage = "Failed to load streams: \(message)"
178+ self.showError = true
179+ }
180+ isLoadingStreams = false
181+ }
182+
183+ // MARK: - Connection CRUD
184+
185+ @MainActor
186+ func addConnection(_ connection: ServerConnection) {
187+ connections.append(connection)
188+ ConnectionStore.saveConnections(connections)
189+ }
190+
191+ @MainActor
192+ func updateConnection(_ connection: ServerConnection) {
193+ if let index = connections.firstIndex(where: { $0.id == connection.id }) {
194+ connections[index] = connection
195+ ConnectionStore.saveConnections(connections)
196+ }
197+ }
198+
199+ @MainActor
200+ func removeConnection(_ connection: ServerConnection) {
201+ connections.removeAll { $0.id == connection.id }
202+ ConnectionStore.deleteConnection(connection)
203+ ConnectionStore.saveConnections(connections)
204+ if activeConnection?.id == connection.id {
205+ disconnect()
206+ }
207+ }
208+
209+ // MARK: - Stream Management
210+
211+ @MainActor
212+ func createStream(name: String) async throws {
213+ guard let client else { throw ParseableError.notConnected }
214+ try await client.createStream(name: name)
215+ await refreshStreams()
216+ }
217+
218+ @MainActor
219+ func deleteStream(name: String) async throws {
220+ guard let client else { throw ParseableError.notConnected }
221+ try await client.deleteStream(name: name)
222+ if selectedStream == name {
223+ selectedStream = nil
224+ }
225+ // Remove saved queries that referenced the deleted stream
226+ let orphaned = savedQueries.filter { $0.stream == name }
227+ if !orphaned.isEmpty {
228+ savedQueries.removeAll { $0.stream == name }
229+ SavedQueryStore.save(savedQueries)
230+ }
231+ await refreshStreams()
232+ }
233+
234+ // MARK: - Saved Queries
235+
236+ @MainActor
237+ func addSavedQuery(_ query: SavedQuery) {
238+ savedQueries.append(query)
239+ SavedQueryStore.save(savedQueries)
240+ }
241+
242+ @MainActor
243+ func removeSavedQuery(_ query: SavedQuery) {
244+ savedQueries.removeAll { $0.id == query.id }
245+ SavedQueryStore.save(savedQueries)
246+ }
247+
248+ // MARK: - Error Handling
249+
250+ @MainActor
251+ func showErrorMessage(_ message: String) {
252+ errorMessage = message
253+ showError = true
254+ }
255+}
ParseableViewer/App/ParseableViewerApp.swiftnew file+49
@@ -0,0 +1,49 @@
1+import SwiftUI
2+
3+@main
4+struct ParseableViewerApp: App {
5+ @State private var appState = AppState()
6+
7+ var body: some Scene {
8+ WindowGroup {
9+ ContentView()
10+ .environment(appState)
11+ .frame(minWidth: 900, minHeight: 600)
12+ }
13+ .windowStyle(.titleBar)
14+ .defaultSize(width: 1200, height: 800)
15+ .commands {
16+ CommandGroup(after: .newItem) {
17+ Button("New Connection...") {
18+ appState.editingConnection = nil
19+ appState.showConnectionSheet = true
20+ }
21+ .keyboardShortcut("n", modifiers: [.command, .shift])
22+
23+ Divider()
24+
25+ Button("Refresh Streams") {
26+ Task {
27+ await appState.refreshStreams()
28+ }
29+ }
30+ .keyboardShortcut("r", modifiers: [.command])
31+ .disabled(!appState.isConnected)
32+ }
33+
34+ CommandGroup(replacing: .sidebar) {
35+ Button("Toggle Sidebar") {
36+ NSApp.keyWindow?.firstResponder?.tryToPerform(
37+ #selector(NSSplitViewController.toggleSidebar(_:)), with: nil
38+ )
39+ }
40+ .keyboardShortcut("s", modifiers: [.command, .control])
41+ }
42+ }
43+
44+ Settings {
45+ SettingsView()
46+ .environment(appState)
47+ }
48+ }
49+}
ParseableViewer/Info.plistnew file+32
@@ -0,0 +1,32 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+<plist version="1.0">
4+<dict>
5+ <key>CFBundleDevelopmentRegion</key>
6+ <string>en</string>
7+ <key>CFBundleDisplayName</key>
8+ <string>Parseable Viewer</string>
9+ <key>CFBundleExecutable</key>
10+ <string>$(EXECUTABLE_NAME)</string>
11+ <key>CFBundleIdentifier</key>
12+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13+ <key>CFBundleInfoDictionaryVersion</key>
14+ <string>6.0</string>
15+ <key>CFBundleName</key>
16+ <string>$(PRODUCT_NAME)</string>
17+ <key>CFBundlePackageType</key>
18+ <string>APPL</string>
19+ <key>CFBundleShortVersionString</key>
20+ <string>$(MARKETING_VERSION)</string>
21+ <key>CFBundleVersion</key>
22+ <string>$(CURRENT_PROJECT_VERSION)</string>
23+ <key>LSApplicationCategoryType</key>
24+ <string>public.app-category.developer-tools</string>
25+ <key>LSMinimumSystemVersion</key>
26+ <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
27+ <key>NSMainNibFile</key>
28+ <string></string>
29+ <key>NSPrincipalClass</key>
30+ <string>NSApplication</string>
31+</dict>
32+</plist>
ParseableViewer/Models/AlertModels.swiftnew file+54
@@ -0,0 +1,54 @@
1+import Foundation
2+
3+struct AlertConfig: Codable, Sendable {
4+ let alerts: [AlertRule]?
5+ let version: String?
6+
7+ init(alerts: [AlertRule]?, version: String?) {
8+ self.alerts = alerts
9+ self.version = version
10+ }
11+
12+ init(from decoder: Decoder) throws {
13+ // Handle both {"alerts": [...]} and direct array
14+ if let container = try? decoder.container(keyedBy: CodingKeys.self) {
15+ self.alerts = try? container.decode([AlertRule].self, forKey: .alerts)
16+ self.version = try? container.decode(String.self, forKey: .version)
17+ } else {
18+ let container = try decoder.singleValueContainer()
19+ self.alerts = try? container.decode([AlertRule].self)
20+ self.version = nil
21+ }
22+ }
23+
24+ private enum CodingKeys: String, CodingKey {
25+ case alerts, version
26+ }
27+}
28+
29+struct AlertRule: Identifiable, Codable, Sendable {
30+ let name: String
31+ let message: String?
32+ let rule: AlertRuleSpec?
33+ let targets: [AlertTarget]?
34+
35+ var id: String { name }
36+}
37+
38+struct AlertRuleSpec: Codable, Sendable {
39+ let type: String?
40+ let config: String?
41+}
42+
43+struct AlertTarget: Codable, Sendable {
44+ let type: String?
45+ let endpoint: String?
46+ let repeatInterval: String?
47+ let repeatTimes: Int?
48+
49+ private enum CodingKeys: String, CodingKey {
50+ case type, endpoint
51+ case repeatInterval = "repeat_interval"
52+ case repeatTimes = "repeat_times"
53+ }
54+}
ParseableViewer/Models/JSONValue.swiftnew file+182
@@ -0,0 +1,182 @@
1+import Foundation
2+
3+/// A type-safe representation of arbitrary JSON values.
4+enum JSONValue: Codable, Hashable, Sendable, Comparable {
5+ case null
6+ case bool(Bool)
7+ case int(Int)
8+ case double(Double)
9+ case string(String)
10+ case array([JSONValue])
11+ case object([String: JSONValue])
12+
13+ init(from decoder: Decoder) throws {
14+ let container = try decoder.singleValueContainer()
15+ if container.decodeNil() {
16+ self = .null
17+ } else if let value = try? container.decode(Bool.self) {
18+ self = .bool(value)
19+ } else if let value = try? container.decode(Int.self) {
20+ self = .int(value)
21+ } else if let value = try? container.decode(Double.self) {
22+ self = .double(value)
23+ } else if let value = try? container.decode(String.self) {
24+ self = .string(value)
25+ } else if let value = try? container.decode([JSONValue].self) {
26+ self = .array(value)
27+ } else if let value = try? container.decode([String: JSONValue].self) {
28+ self = .object(value)
29+ } else {
30+ self = .null
31+ }
32+ }
33+
34+ func encode(to encoder: Encoder) throws {
35+ var container = encoder.singleValueContainer()
36+ switch self {
37+ case .null: try container.encodeNil()
38+ case .bool(let v): try container.encode(v)
39+ case .int(let v): try container.encode(v)
40+ case .double(let v): try container.encode(v)
41+ case .string(let v): try container.encode(v)
42+ case .array(let v): try container.encode(v)
43+ case .object(let v): try container.encode(v)
44+ }
45+ }
46+
47+ var displayString: String {
48+ switch self {
49+ case .null: return "null"
50+ case .bool(let v): return v ? "true" : "false"
51+ case .int(let v): return String(v)
52+ case .double(let v):
53+ if v == v.rounded() && abs(v) < 1e15 {
54+ return String(format: "%.0f", v)
55+ }
56+ return String(v)
57+ case .string(let v): return v
58+ case .array(let v): return "[\(v.count) items]"
59+ case .object(let v): return "{\(v.count) fields}"
60+ }
61+ }
62+
63+ /// Like `displayString` but serializes arrays/objects as compact JSON
64+ /// instead of placeholders like "[3 items]". Used for CSV export.
65+ var exportString: String {
66+ switch self {
67+ case .array, .object:
68+ if let data = try? JSONEncoder().encode(self),
69+ let json = String(data: data, encoding: .utf8) {
70+ return json
71+ }
72+ return displayString
73+ default:
74+ return displayString
75+ }
76+ }
77+
78+ var isScalar: Bool {
79+ switch self {
80+ case .array, .object: return false
81+ default: return true
82+ }
83+ }
84+
85+ var stringValue: String? {
86+ if case .string(let v) = self { return v }
87+ return nil
88+ }
89+
90+ var objectValue: [String: JSONValue]? {
91+ if case .object(let v) = self { return v }
92+ return nil
93+ }
94+
95+ var arrayValue: [JSONValue]? {
96+ if case .array(let v) = self { return v }
97+ return nil
98+ }
99+
100+ func prettyPrinted(indent: Int = 0, maxDepth: Int = 50) -> String {
101+ if indent >= maxDepth {
102+ return displayString
103+ }
104+
105+ let pad = String(repeating: " ", count: indent)
106+ let innerPad = String(repeating: " ", count: indent + 1)
107+
108+ switch self {
109+ case .null: return "null"
110+ case .bool(let v): return v ? "true" : "false"
111+ case .int(let v): return String(v)
112+ case .double(let v): return String(v)
113+ case .string(let v): return "\"\(Self.escapeJSONString(v))\""
114+ case .array(let items):
115+ if items.isEmpty { return "[]" }
116+ if items.allSatisfy({ $0.isScalar }) && items.count <= 5 {
117+ return "[\(items.map { $0.prettyPrinted(maxDepth: maxDepth) }.joined(separator: ", "))]"
118+ }
119+ let lines = items.map { "\(innerPad)\($0.prettyPrinted(indent: indent + 1, maxDepth: maxDepth))" }
120+ return "[\n\(lines.joined(separator: ",\n"))\n\(pad)]"
121+ case .object(let dict):
122+ if dict.isEmpty { return "{}" }
123+ let sortedKeys = dict.keys.sorted()
124+ let lines = sortedKeys.compactMap { key -> String? in
125+ guard let value = dict[key] else { return nil }
126+ return "\(innerPad)\"\(Self.escapeJSONString(key))\": \(value.prettyPrinted(indent: indent + 1, maxDepth: maxDepth))"
127+ }
128+ return "{\n\(lines.joined(separator: ",\n"))\n\(pad)}"
129+ }
130+ }
131+
132+ /// Escapes special characters for valid JSON string output.
133+ static func escapeJSONString(_ s: String) -> String {
134+ var result = ""
135+ result.reserveCapacity(s.count)
136+ for char in s {
137+ switch char {
138+ case "\"": result += "\\\""
139+ case "\\": result += "\\\\"
140+ case "\n": result += "\\n"
141+ case "\r": result += "\\r"
142+ case "\t": result += "\\t"
143+ default:
144+ if char.asciiValue.map({ $0 < 0x20 }) == true {
145+ let code = char.asciiValue!
146+ result += String(format: "\\u%04x", code)
147+ } else {
148+ result.append(char)
149+ }
150+ }
151+ }
152+ return result
153+ }
154+
155+ /// Type-aware comparison: numbers compare numerically, strings
156+ /// lexicographically, nulls sort first, bools sort false < true.
157+ /// Cross-type comparisons fall back to displayString.
158+ static func < (lhs: JSONValue, rhs: JSONValue) -> Bool {
159+ switch (lhs, rhs) {
160+ case (.null, .null): return false
161+ case (.null, _): return true
162+ case (_, .null): return false
163+ case (.bool(let a), .bool(let b)): return !a && b
164+ case (.int(let a), .int(let b)): return a < b
165+ case (.double(let a), .double(let b)): return a < b
166+ case (.int(let a), .double(let b)): return Double(a) < b
167+ case (.double(let a), .int(let b)): return a < Double(b)
168+ case (.string(let a), .string(let b)):
169+ return a.localizedStandardCompare(b) == .orderedAscending
170+ default:
171+ return lhs.displayString < rhs.displayString
172+ }
173+ }
174+}
175+
176+typealias LogRecord = [String: JSONValue]
177+
178+/// Wrapper for query responses that may contain records + fields metadata.
179+struct QueryResponse: Codable, Sendable {
180+ let records: [LogRecord]
181+ let fields: [String]?
182+}
ParseableViewer/Models/LogStream.swiftnew file+172
@@ -0,0 +1,172 @@
1+import Foundation
2+
3+struct LogStream: Identifiable, Codable, Hashable, Sendable {
4+ let name: String
5+
6+ var id: String { name }
7+
8+ init(name: String) {
9+ self.name = name
10+ }
11+
12+ init(from decoder: Decoder) throws {
13+ // Handle both {"name": "x"} and plain string formats
14+ if let container = try? decoder.container(keyedBy: CodingKeys.self) {
15+ self.name = try container.decode(String.self, forKey: .name)
16+ } else {
17+ let container = try decoder.singleValueContainer()
18+ self.name = try container.decode(String.self)
19+ }
20+ }
21+
22+ private enum CodingKeys: String, CodingKey {
23+ case name
24+ }
25+}
26+
27+struct StreamSchema: Codable, Sendable {
28+ let fields: [SchemaField]
29+
30+ init(fields: [SchemaField]) {
31+ self.fields = fields
32+ }
33+
34+ init(from decoder: Decoder) throws {
35+ // Handle both {"fields": [...]} and direct array formats
36+ if let container = try? decoder.container(keyedBy: CodingKeys.self) {
37+ self.fields = try container.decode([SchemaField].self, forKey: .fields)
38+ } else {
39+ let container = try decoder.singleValueContainer()
40+ self.fields = try container.decode([SchemaField].self)
41+ }
42+ }
43+
44+ private enum CodingKeys: String, CodingKey {
45+ case fields
46+ }
47+}
48+
49+struct SchemaField: Identifiable, Codable, Hashable, Sendable {
50+ let name: String
51+ let dataType: String
52+
53+ var id: String { name }
54+
55+ private enum CodingKeys: String, CodingKey {
56+ case name
57+ case dataType = "data_type"
58+ }
59+
60+ init(name: String, dataType: String) {
61+ self.name = name
62+ self.dataType = dataType
63+ }
64+
65+ init(from decoder: Decoder) throws {
66+ let container = try decoder.container(keyedBy: CodingKeys.self)
67+ self.name = try container.decode(String.self, forKey: .name)
68+ // data_type can be a string or an object
69+ if let typeStr = try? container.decode(String.self, forKey: .dataType) {
70+ self.dataType = typeStr
71+ } else if let typeObj = try? container.decode(JSONValue.self, forKey: .dataType) {
72+ self.dataType = typeObj.displayString
73+ } else {
74+ self.dataType = "Unknown"
75+ }
76+ }
77+}
78+
79+struct StreamStats: Codable, Sendable {
80+ let ingestion: IngestionStats?
81+ let storage: StorageStats?
82+ let stream: String?
83+ let time: String?
84+
85+ struct IngestionStats: Codable, Sendable {
86+ let count: Int?
87+ let size: String?
88+ let format: String?
89+ let lifetime_count: Int?
90+ let lifetime_size: String?
91+
92+ init(from decoder: Decoder) throws {
93+ let container = try decoder.container(keyedBy: CodingKeys.self)
94+ self.count = try? container.decode(Int.self, forKey: .count)
95+ self.format = try? container.decode(String.self, forKey: .format)
96+ self.lifetime_count = try? container.decode(Int.self, forKey: .lifetime_count)
97+ // Handle size as string or number
98+ if let s = try? container.decode(String.self, forKey: .size) {
99+ self.size = s
100+ } else if let n = try? container.decode(Int.self, forKey: .size) {
101+ self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
102+ } else {
103+ self.size = nil
104+ }
105+ if let s = try? container.decode(String.self, forKey: .lifetime_size) {
106+ self.lifetime_size = s
107+ } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
108+ self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
109+ } else {
110+ self.lifetime_size = nil
111+ }
112+ }
113+
114+ private enum CodingKeys: String, CodingKey {
115+ case count, size, format, lifetime_count, lifetime_size
116+ }
117+ }
118+
119+ struct StorageStats: Codable, Sendable {
120+ let size: String?
121+ let type: String?
122+ let lifetime_size: String?
123+
124+ init(from decoder: Decoder) throws {
125+ let container = try decoder.container(keyedBy: CodingKeys.self)
126+ self.type = try? container.decode(String.self, forKey: .type)
127+ if let s = try? container.decode(String.self, forKey: .size) {
128+ self.size = s
129+ } else if let n = try? container.decode(Int.self, forKey: .size) {
130+ self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
131+ } else {
132+ self.size = nil
133+ }
134+ if let s = try? container.decode(String.self, forKey: .lifetime_size) {
135+ self.lifetime_size = s
136+ } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
137+ self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
138+ } else {
139+ self.lifetime_size = nil
140+ }
141+ }
142+
143+ private enum CodingKeys: String, CodingKey {
144+ case size, type, lifetime_size
145+ }
146+ }
147+}
148+
149+struct StreamInfo: Codable, Sendable {
150+ let createdAt: String?
151+ let firstEventAt: String?
152+ let cacheEnabled: Bool?
153+ let timePartition: String?
154+ let staticSchemaFlag: Bool?
155+
156+ private enum CodingKeys: String, CodingKey {
157+ case createdAt = "created-at"
158+ case firstEventAt = "first-event-at"
159+ case cacheEnabled = "cache-enabled"
160+ case timePartition = "time-partition"
161+ case staticSchemaFlag = "static-schema-flag"
162+ }
163+
164+ init(from decoder: Decoder) throws {
165+ let container = try decoder.container(keyedBy: CodingKeys.self)
166+ self.createdAt = try? container.decode(String.self, forKey: .createdAt)
167+ self.firstEventAt = try? container.decode(String.self, forKey: .firstEventAt)
168+ self.cacheEnabled = try? container.decode(Bool.self, forKey: .cacheEnabled)
169+ self.timePartition = try? container.decode(String.self, forKey: .timePartition)
170+ self.staticSchemaFlag = try? container.decode(Bool.self, forKey: .staticSchemaFlag)
171+ }
172+}
ParseableViewer/Models/ServerConnection.swiftnew file+54
@@ -0,0 +1,54 @@
1+import Foundation
2+
3+struct ServerConnection: Identifiable, Codable, Hashable, Sendable {
4+ var id: UUID
5+ var name: String
6+ var url: String
7+ var username: String
8+ /// Password is stored in Keychain, not serialized to UserDefaults.
9+ var password: String
10+
11+ private enum CodingKeys: String, CodingKey {
12+ case id, name, url, username
13+ }
14+
15+ init(id: UUID = UUID(), name: String, url: String, username: String, password: String) {
16+ self.id = id
17+ self.name = name
18+ self.url = url
19+ self.username = username
20+ self.password = password
21+ }
22+
23+ init(from decoder: Decoder) throws {
24+ let container = try decoder.container(keyedBy: CodingKeys.self)
25+ self.id = try container.decode(UUID.self, forKey: .id)
26+ self.name = try container.decode(String.self, forKey: .name)
27+ self.url = try container.decode(String.self, forKey: .url)
28+ self.username = try container.decode(String.self, forKey: .username)
29+ self.password = KeychainService.loadPassword(for: self.id) ?? ""
30+ }
31+
32+ func encode(to encoder: Encoder) throws {
33+ var container = encoder.container(keyedBy: CodingKeys.self)
34+ try container.encode(id, forKey: .id)
35+ try container.encode(name, forKey: .name)
36+ try container.encode(url, forKey: .url)
37+ try container.encode(username, forKey: .username)
38+ }
39+
40+ var baseURL: URL? {
41+ var urlString = url.trimmingCharacters(in: .whitespacesAndNewlines)
42+ if urlString.isEmpty { return nil }
43+ if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
44+ urlString = "https://\(urlString)"
45+ }
46+ while urlString.hasSuffix("/") {
47+ urlString.removeLast()
48+ }
49+ guard let parsed = URL(string: urlString), parsed.host != nil else {
50+ return nil
51+ }
52+ return parsed
53+ }
54+}
ParseableViewer/Models/ServerInfo.swiftnew file+98
@@ -0,0 +1,98 @@
1+import Foundation
2+
3+struct ServerAbout: Codable, Sendable {
4+ let version: String?
5+ let uiVersion: String?
6+ let commit: String?
7+ let deploymentId: String?
8+ let mode: String?
9+ let staging: String?
10+ let store: StoreInfo?
11+ let license: String?
12+ let grpcPort: Int?
13+ let updateAvailable: Bool?
14+ let latestVersion: String?
15+ let llmActive: Bool?
16+ let oidcActive: Bool?
17+
18+ private enum CodingKeys: String, CodingKey {
19+ case version, commit, mode, staging, store, license
20+ case uiVersion = "ui_version"
21+ case deploymentId = "deployment_id"
22+ case grpcPort = "grpc_port"
23+ case updateAvailable
24+ case latestVersion
25+ case llmActive
26+ case oidcActive
27+ }
28+
29+ init(from decoder: Decoder) throws {
30+ let container = try decoder.container(keyedBy: CodingKeys.self)
31+ self.version = try? container.decode(String.self, forKey: .version)
32+ self.uiVersion = try? container.decode(String.self, forKey: .uiVersion)
33+ self.commit = try? container.decode(String.self, forKey: .commit)
34+ self.deploymentId = try? container.decode(String.self, forKey: .deploymentId)
35+ self.mode = try? container.decode(String.self, forKey: .mode)
36+ self.staging = try? container.decode(String.self, forKey: .staging)
37+ self.store = try? container.decode(StoreInfo.self, forKey: .store)
38+ self.license = try? container.decode(String.self, forKey: .license)
39+ self.grpcPort = try? container.decode(Int.self, forKey: .grpcPort)
40+ self.updateAvailable = try? container.decode(Bool.self, forKey: .updateAvailable)
41+ self.latestVersion = try? container.decode(String.self, forKey: .latestVersion)
42+ self.llmActive = try? container.decode(Bool.self, forKey: .llmActive)
43+ self.oidcActive = try? container.decode(Bool.self, forKey: .oidcActive)
44+ }
45+}
46+
47+struct StoreInfo: Codable, Sendable {
48+ let type: String?
49+ let path: String?
50+
51+ private enum CodingKeys: String, CodingKey {
52+ case type, path
53+ }
54+
55+ init(from decoder: Decoder) throws {
56+ let container = try decoder.container(keyedBy: CodingKeys.self)
57+ self.type = try? container.decode(String.self, forKey: .type)
58+ self.path = try? container.decode(String.self, forKey: .path)
59+ }
60+}
61+
62+struct RetentionConfig: Codable, Sendable {
63+ let description: String?
64+ let duration: String?
65+ let action: String?
66+
67+ init(from decoder: Decoder) throws {
68+ let container = try decoder.container(keyedBy: CodingKeys.self)
69+ self.description = try? container.decode(String.self, forKey: .description)
70+ self.duration = try? container.decode(String.self, forKey: .duration)
71+ self.action = try? container.decode(String.self, forKey: .action)
72+ }
73+
74+ private enum CodingKeys: String, CodingKey {
75+ case description, duration, action
76+ }
77+}
78+
79+struct UserInfo: Identifiable, Codable, Sendable {
80+ let id: String
81+ let method: String?
82+
83+ private enum CodingKeys: String, CodingKey {
84+ case id, method
85+ }
86+
87+ init(from decoder: Decoder) throws {
88+ // Handle both {"id": "x", ...} and variations
89+ if let container = try? decoder.container(keyedBy: CodingKeys.self) {
90+ self.id = try container.decode(String.self, forKey: .id)
91+ self.method = try? container.decode(String.self, forKey: .method)
92+ } else {
93+ let container = try decoder.singleValueContainer()
94+ self.id = try container.decode(String.self)
95+ self.method = nil
96+ }
97+ }
98+}
ParseableViewer/ParseableViewer.entitlementsnew file+10
@@ -0,0 +1,10 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+<plist version="1.0">
4+<dict>
5+ <key>com.apple.security.app-sandbox</key>
6+ <true/>
7+ <key>com.apple.security.network.client</key>
8+ <true/>
9+</dict>
10+</plist>
ParseableViewer/Resources/Assets.xcassets/AccentColor.colorset/Contents.jsonnew file+38
@@ -0,0 +1,38 @@
1+{
2+ "colors" : [
3+ {
4+ "color" : {
5+ "color-space" : "srgb",
6+ "components" : {
7+ "alpha" : "1.000",
8+ "blue" : "0.996",
9+ "green" : "0.459",
10+ "red" : "0.290"
11+ }
12+ },
13+ "idiom" : "universal"
14+ },
15+ {
16+ "appearances" : [
17+ {
18+ "appearance" : "luminosity",
19+ "value" : "dark"
20+ }
21+ ],
22+ "color" : {
23+ "color-space" : "srgb",
24+ "components" : {
25+ "alpha" : "1.000",
26+ "blue" : "0.996",
27+ "green" : "0.529",
28+ "red" : "0.380"
29+ }
30+ },
31+ "idiom" : "universal"
32+ }
33+ ],
34+ "info" : {
35+ "author" : "xcode",
36+ "version" : 1
37+ }
38+}
ParseableViewer/Resources/Assets.xcassets/AppIcon.appiconset/Contents.jsonnew file+58
@@ -0,0 +1,58 @@
1+{
2+ "images" : [
3+ {
4+ "idiom" : "mac",
5+ "scale" : "1x",
6+ "size" : "16x16"
7+ },
8+ {
9+ "idiom" : "mac",
10+ "scale" : "2x",
11+ "size" : "16x16"
12+ },
13+ {
14+ "idiom" : "mac",
15+ "scale" : "1x",
16+ "size" : "32x32"
17+ },
18+ {
19+ "idiom" : "mac",
20+ "scale" : "2x",
21+ "size" : "32x32"
22+ },
23+ {
24+ "idiom" : "mac",
25+ "scale" : "1x",
26+ "size" : "128x128"
27+ },
28+ {
29+ "idiom" : "mac",
30+ "scale" : "2x",
31+ "size" : "128x128"
32+ },
33+ {
34+ "idiom" : "mac",
35+ "scale" : "1x",
36+ "size" : "256x256"
37+ },
38+ {
39+ "idiom" : "mac",
40+ "scale" : "2x",
41+ "size" : "256x256"
42+ },
43+ {
44+ "idiom" : "mac",
45+ "scale" : "1x",
46+ "size" : "512x512"
47+ },
48+ {
49+ "idiom" : "mac",
50+ "scale" : "2x",
51+ "size" : "512x512"
52+ }
53+ ],
54+ "info" : {
55+ "author" : "xcode",
56+ "version" : 1
57+ }
58+}
ParseableViewer/Resources/Assets.xcassets/Contents.jsonnew file+6
@@ -0,0 +1,6 @@
1+{
2+ "info" : {
3+ "author" : "xcode",
4+ "version" : 1
5+ }
6+}
ParseableViewer/Services/ConnectionStore.swiftnew file+74
@@ -0,0 +1,74 @@
1+import Foundation
2+
3+final class ConnectionStore {
4+ private static let storageKey = "parseable_connections"
5+ private static let activeConnectionKey = "parseable_active_connection_id"
6+
7+ static func loadConnections() -> [ServerConnection] {
8+ guard let data = UserDefaults.standard.data(forKey: storageKey) else {
9+ return []
10+ }
11+ return (try? JSONDecoder().decode([ServerConnection].self, from: data)) ?? []
12+ }
13+
14+ static func saveConnections(_ connections: [ServerConnection]) {
15+ for connection in connections {
16+ if !connection.password.isEmpty {
17+ KeychainService.savePassword(connection.password, for: connection.id)
18+ } else {
19+ KeychainService.deletePassword(for: connection.id)
20+ }
21+ }
22+ if let data = try? JSONEncoder().encode(connections) {
23+ UserDefaults.standard.set(data, forKey: storageKey)
24+ }
25+ }
26+
27+ static func deleteConnection(_ connection: ServerConnection) {
28+ KeychainService.deletePassword(for: connection.id)
29+ }
30+
31+ static func loadActiveConnectionID() -> UUID? {
32+ guard let string = UserDefaults.standard.string(forKey: activeConnectionKey) else {
33+ return nil
34+ }
35+ return UUID(uuidString: string)
36+ }
37+
38+ static func saveActiveConnectionID(_ id: UUID?) {
39+ UserDefaults.standard.set(id?.uuidString, forKey: activeConnectionKey)
40+ }
41+}
42+
43+final class SavedQueryStore {
44+ private static let storageKey = "parseable_saved_queries"
45+
46+ static func load() -> [SavedQuery] {
47+ guard let data = UserDefaults.standard.data(forKey: storageKey) else {
48+ return []
49+ }
50+ return (try? JSONDecoder().decode([SavedQuery].self, from: data)) ?? []
51+ }
52+
53+ static func save(_ queries: [SavedQuery]) {
54+ if let data = try? JSONEncoder().encode(queries) {
55+ UserDefaults.standard.set(data, forKey: storageKey)
56+ }
57+ }
58+}
59+
60+struct SavedQuery: Identifiable, Codable, Hashable, Sendable {
61+ var id: UUID
62+ var name: String
63+ var sql: String
64+ var stream: String
65+ var createdAt: Date
66+
67+ init(id: UUID = UUID(), name: String, sql: String, stream: String, createdAt: Date = Date()) {
68+ self.id = id
69+ self.name = name
70+ self.sql = sql
71+ self.stream = stream
72+ self.createdAt = createdAt
73+ }
74+}
ParseableViewer/Services/KeychainService.swiftnew file+61
@@ -0,0 +1,61 @@
1+import Foundation
2+import os
3+import Security
4+
5+enum KeychainService {
6+ private static let service = "com.parseableviewer.app"
7+ private static let logger = Logger(subsystem: service, category: "Keychain")
8+
9+ @discardableResult
10+ static func savePassword(_ password: String, for connectionID: UUID) -> Bool {
11+ let account = connectionID.uuidString
12+ deletePassword(for: connectionID)
13+
14+ guard let data = password.data(using: .utf8) else { return false }
15+ let query: [String: Any] = [
16+ kSecClass as String: kSecClassGenericPassword,
17+ kSecAttrService as String: service,
18+ kSecAttrAccount as String: account,
19+ kSecValueData as String: data
20+ ]
21+ let status = SecItemAdd(query as CFDictionary, nil)
22+ if status != errSecSuccess {
23+ logger.error("Failed to save password for \(account): OSStatus \(status)")
24+ }
25+ return status == errSecSuccess
26+ }
27+
28+ static func loadPassword(for connectionID: UUID) -> String? {
29+ let account = connectionID.uuidString
30+ let query: [String: Any] = [
31+ kSecClass as String: kSecClassGenericPassword,
32+ kSecAttrService as String: service,
33+ kSecAttrAccount as String: account,
34+ kSecReturnData as String: true,
35+ kSecMatchLimit as String: kSecMatchLimitOne
36+ ]
37+
38+ var result: AnyObject?
39+ let status = SecItemCopyMatching(query as CFDictionary, &result)
40+ if status != errSecSuccess && status != errSecItemNotFound {
41+ logger.error("Failed to load password for \(account): OSStatus \(status)")
42+ }
43+ guard status == errSecSuccess, let data = result as? Data else { return nil }
44+ return String(data: data, encoding: .utf8)
45+ }
46+
47+ @discardableResult
48+ static func deletePassword(for connectionID: UUID) -> Bool {
49+ let account = connectionID.uuidString
50+ let query: [String: Any] = [
51+ kSecClass as String: kSecClassGenericPassword,
52+ kSecAttrService as String: service,
53+ kSecAttrAccount as String: account
54+ ]
55+ let status = SecItemDelete(query as CFDictionary)
56+ if status != errSecSuccess && status != errSecItemNotFound {
57+ logger.error("Failed to delete password for \(account): OSStatus \(status)")
58+ }
59+ return status == errSecSuccess || status == errSecItemNotFound
60+ }
61+}
ParseableViewer/Services/ParseableClient.swiftnew file+280
@@ -0,0 +1,280 @@
1+import Foundation
2+
3+enum ParseableError: LocalizedError {
4+ case invalidURL
5+ case invalidResponse
6+ case serverError(Int, String)
7+ case decodingError(String)
8+ case notConnected
9+ case unauthorized
10+
11+ var errorDescription: String? {
12+ switch self {
13+ case .invalidURL: return "Invalid server URL"
14+ case .invalidResponse: return "Invalid response from server"
15+ case .serverError(let code, let msg): return "Server error (\(code)): \(msg)"
16+ case .decodingError(let msg): return "Failed to decode response: \(msg)"
17+ case .notConnected: return "Not connected to server"
18+ case .unauthorized: return "Authentication failed. Check your credentials."
19+ }
20+ }
21+
22+ /// Maps any error into a user-friendly message, handling ParseableError cases
23+ /// and common NSURLError codes.
24+ static func userFriendlyMessage(for error: Error) -> String {
25+ if let parseableError = error as? ParseableError {
26+ switch parseableError {
27+ case .unauthorized:
28+ return "Authentication failed. Check your username and password."
29+ case .invalidURL:
30+ return "The server URL is invalid. Check the format (e.g. https://host:port)."
31+ case .serverError(let code, _):
32+ return "Server returned error \(code). Check that the URL points to a Parseable instance."
33+ default:
34+ return parseableError.localizedDescription
35+ }
36+ }
37+ let nsError = error as NSError
38+ if nsError.domain == NSURLErrorDomain {
39+ switch nsError.code {
40+ case NSURLErrorNotConnectedToInternet, NSURLErrorNetworkConnectionLost:
41+ return "No internet connection. Check your network and try again."
42+ case NSURLErrorCannotFindHost:
43+ return "Cannot find server. Check the URL and your network connection."
44+ case NSURLErrorCannotConnectToHost:
45+ return "Cannot connect to server. Check the URL and that the server is running."
46+ case NSURLErrorTimedOut:
47+ return "Connection timed out. The server may be unreachable."
48+ case NSURLErrorSecureConnectionFailed, NSURLErrorServerCertificateUntrusted:
49+ return "SSL/TLS error. The server certificate may be invalid or untrusted."
50+ default:
51+ return "Network error: \(error.localizedDescription)"
52+ }
53+ }
54+ return error.localizedDescription
55+ }
56+}
57+
58+final class ParseableClient: Sendable {
59+ let baseURL: URL
60+ let username: String
61+ let password: String
62+ private let session: URLSession
63+
64+ private static let isoFormatter: ISO8601DateFormatter = {
65+ let f = ISO8601DateFormatter()
66+ f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
67+ return f
68+ }()
69+
70+ init(url: URL, username: String, password: String) {
71+ self.baseURL = url
72+ self.username = username
73+ self.password = password
74+
75+ let config = URLSessionConfiguration.default
76+ config.timeoutIntervalForRequest = 30
77+ config.timeoutIntervalForResource = 120
78+ self.session = URLSession(configuration: config)
79+ }
80+
81+ convenience init(connection: ServerConnection) throws {
82+ guard let url = connection.baseURL else {
83+ throw ParseableError.invalidURL
84+ }
85+ self.init(url: url, username: connection.username, password: connection.password)
86+ }
87+
88+ deinit {
89+ // Use finishTasksAndInvalidate so in-flight requests (e.g. a query
90+ // the user just ran) can complete rather than being silently cancelled.
91+ session.finishTasksAndInvalidate()
92+ }
93+
94+ private var authHeader: String {
95+ let credentials = "\(username):\(password)"
96+ guard let data = credentials.data(using: .utf8) else {
97+ return "Basic "
98+ }
99+ return "Basic \(data.base64EncodedString())"
100+ }
101+
102+ private func buildRequest(method: String, path: String, body: Data? = nil, queryItems: [URLQueryItem]? = nil) throws -> URLRequest {
103+ var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)
104+ if let queryItems {
105+ components?.queryItems = queryItems
106+ }
107+ guard let url = components?.url else {
108+ throw ParseableError.invalidURL
109+ }
110+
111+ var request = URLRequest(url: url)
112+ request.httpMethod = method
113+ request.setValue(authHeader, forHTTPHeaderField: "Authorization")
114+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
115+ request.setValue("application/json", forHTTPHeaderField: "Accept")
116+ if let body {
117+ request.httpBody = body
118+ }
119+ return request
120+ }
121+
122+ private func performRequest(method: String, path: String, body: Data? = nil, queryItems: [URLQueryItem]? = nil) async throws -> Data {
123+ let request = try buildRequest(method: method, path: path, body: body, queryItems: queryItems)
124+ let (data, response) = try await session.data(for: request)
125+
126+ guard let httpResponse = response as? HTTPURLResponse else {
127+ throw ParseableError.invalidResponse
128+ }
129+
130+ if httpResponse.statusCode == 401 {
131+ throw ParseableError.unauthorized
132+ }
133+
134+ guard (200...299).contains(httpResponse.statusCode) else {
135+ let message = String(data: data, encoding: .utf8) ?? "Unknown error"
136+ throw ParseableError.serverError(httpResponse.statusCode, message)
137+ }
138+
139+ return data
140+ }
141+
142+ // MARK: - Health / System
143+
144+ func checkHealth() async throws {
145+ let request = try buildRequest(method: "HEAD", path: "/api/v1/liveness")
146+ let (_, response) = try await session.data(for: request)
147+ guard let httpResponse = response as? HTTPURLResponse else {
148+ throw ParseableError.invalidResponse
149+ }
150+ if httpResponse.statusCode == 401 {
151+ throw ParseableError.unauthorized
152+ }
153+ guard httpResponse.statusCode == 200 else {
154+ throw ParseableError.serverError(httpResponse.statusCode, "Health check failed")
155+ }
156+ }
157+
158+ func getAbout() async throws -> ServerAbout {
159+ let data = try await performRequest(method: "GET", path: "/api/v1/about")
160+ return try JSONDecoder().decode(ServerAbout.self, from: data)
161+ }
162+
163+ // MARK: - Log Streams
164+
165+ func listStreams() async throws -> [LogStream] {
166+ let data = try await performRequest(method: "GET", path: "/api/v1/logstream")
167+ return try JSONDecoder().decode([LogStream].self, from: data)
168+ }
169+
170+ private static func encodePathComponent(_ name: String) throws -> String {
171+ // Use a restrictive character set for path segments: exclude /?#[]@!$&'()*+,;=
172+ var allowed = CharacterSet.urlPathAllowed
173+ allowed.remove(charactersIn: "/?#[]@!$&'()*+,;=")
174+ guard let encoded = name.addingPercentEncoding(withAllowedCharacters: allowed) else {
175+ throw ParseableError.invalidURL
176+ }
177+ return encoded
178+ }
179+
180+ func createStream(name: String) async throws {
181+ let encoded = try Self.encodePathComponent(name)
182+ _ = try await performRequest(method: "PUT", path: "/api/v1/logstream/\(encoded)")
183+ }
184+
185+ func deleteStream(name: String) async throws {
186+ let encoded = try Self.encodePathComponent(name)
187+ _ = try await performRequest(method: "DELETE", path: "/api/v1/logstream/\(encoded)")
188+ }
189+
190+ func getStreamSchema(stream: String) async throws -> StreamSchema {
191+ let encoded = try Self.encodePathComponent(stream)
192+ let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/schema")
193+ return try JSONDecoder().decode(StreamSchema.self, from: data)
194+ }
195+
196+ func getStreamStats(stream: String) async throws -> StreamStats {
197+ let encoded = try Self.encodePathComponent(stream)
198+ let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/stats")
199+ return try JSONDecoder().decode(StreamStats.self, from: data)
200+ }
201+
202+ func getStreamInfo(stream: String) async throws -> StreamInfo {
203+ let encoded = try Self.encodePathComponent(stream)
204+ let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/info")
205+ return try JSONDecoder().decode(StreamInfo.self, from: data)
206+ }
207+
208+ // MARK: - Query
209+
210+ func query(sql: String, startTime: Date, endTime: Date) async throws -> [LogRecord] {
211+ let body: [String: Any] = [
212+ "query": sql,
213+ "startTime": Self.isoFormatter.string(from: startTime),
214+ "endTime": Self.isoFormatter.string(from: endTime)
215+ ]
216+
217+ let bodyData = try JSONSerialization.data(withJSONObject: body)
218+ let responseData = try await performRequest(method: "POST", path: "/api/v1/query", body: bodyData)
219+
220+ if responseData.isEmpty {
221+ return []
222+ }
223+
224+ // Handle both response formats:
225+ // 1. Wrapped: {"records": [...], "fields": [...]}
226+ // 2. Direct array: [...]
227+ if let wrapped = try? JSONDecoder().decode(QueryResponse.self, from: responseData) {
228+ return wrapped.records
229+ }
230+ do {
231+ return try JSONDecoder().decode([LogRecord].self, from: responseData)
232+ } catch {
233+ throw ParseableError.decodingError("Unexpected query response format")
234+ }
235+ }
236+
237+ // MARK: - Alerts
238+
239+ func getAlerts(stream: String) async throws -> AlertConfig {
240+ // Try new API first; only fall back to legacy per-stream endpoint on 404
241+ do {
242+ let data = try await performRequest(method: "GET", path: "/api/v1/alerts")
243+ return try JSONDecoder().decode(AlertConfig.self, from: data)
244+ } catch let error as ParseableError {
245+ if case .serverError(let code, _) = error, code == 404 {
246+ // New endpoint not available, try legacy below
247+ } else {
248+ throw error
249+ }
250+ }
251+ let encoded = try Self.encodePathComponent(stream)
252+ let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/alert")
253+ return try JSONDecoder().decode(AlertConfig.self, from: data)
254+ }
255+
256+ // MARK: - Retention
257+
258+ func getRetention(stream: String) async throws -> [RetentionConfig] {
259+ let encoded = try Self.encodePathComponent(stream)
260+ let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/retention")
261+ if data.isEmpty { return [] }
262+ // Handle both array and single object response
263+ if let array = try? JSONDecoder().decode([RetentionConfig].self, from: data) {
264+ return array
265+ }
266+ do {
267+ let single = try JSONDecoder().decode(RetentionConfig.self, from: data)
268+ return [single]
269+ } catch {
270+ throw ParseableError.decodingError("Unexpected retention response format")
271+ }
272+ }
273+
274+ // MARK: - Users
275+
276+ func listUsers() async throws -> [UserInfo] {
277+ let data = try await performRequest(method: "GET", path: "/api/v1/user")
278+ return try JSONDecoder().decode([UserInfo].self, from: data)
279+ }
280+}
ParseableViewer/ViewModels/LiveTailViewModel.swiftnew file+229
@@ -0,0 +1,229 @@
1+import Foundation
2+import SwiftUI
3+
4+@MainActor
5+@Observable
6+final class LiveTailViewModel {
7+ var entries: [LiveTailEntry] = []
8+ var isRunning = false
9+ var isPaused = false
10+ var filterText = ""
11+ var pollInterval: TimeInterval = 2.0
12+ var maxEntries = 5000
13+ var errorMessage: String?
14+ var droppedCount = 0
15+ private(set) var lastPollTime: Date?
16+
17+ // nonisolated(unsafe) so deinit can invalidate the timer.
18+ // Only mutated from @MainActor methods (start/stop).
19+ nonisolated(unsafe) private var timer: Timer?
20+ private var lastTimestamp: Date?
21+ private var seenFingerprints: Set<String> = []
22+ private var consecutiveErrors = 0
23+ private static let maxConsecutiveErrors = 5
24+
25+ deinit {
26+ timer?.invalidate()
27+ }
28+
29+ // Cached formatters to avoid per-poll allocation
30+ private static let displayFormatter: DateFormatter = {
31+ let f = DateFormatter()
32+ f.dateFormat = "HH:mm:ss.SSS"
33+ return f
34+ }()
35+
36+ private static let isoFractional: ISO8601DateFormatter = {
37+ let f = ISO8601DateFormatter()
38+ f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
39+ return f
40+ }()
41+
42+ private static let isoBasic: ISO8601DateFormatter = {
43+ let f = ISO8601DateFormatter()
44+ f.formatOptions = [.withInternetDateTime]
45+ return f
46+ }()
47+
48+ struct LiveTailEntry: Identifiable, Sendable {
49+ let id = UUID()
50+ let timestamp: Date
51+ let record: LogRecord
52+ let displayTimestamp: String
53+ let summary: String
54+ }
55+
56+ var filteredEntries: [LiveTailEntry] {
57+ guard !filterText.isEmpty else { return entries }
58+ return entries.filter { entry in
59+ entry.summary.localizedCaseInsensitiveContains(filterText) ||
60+ entry.record.values.contains { $0.displayString.localizedCaseInsensitiveContains(filterText) }
61+ }
62+ }
63+
64+ var entryCount: Int { entries.count }
65+ var displayedCount: Int { filteredEntries.count }
66+
67+ func start(client: ParseableClient?, stream: String?) {
68+ guard let client, let stream, !isRunning else { return }
69+
70+ // Read settings from UserDefaults (set by SettingsView @AppStorage)
71+ let storedInterval = UserDefaults.standard.double(forKey: "liveTailPollInterval")
72+ if storedInterval >= 1 { pollInterval = storedInterval }
73+ let storedMax = UserDefaults.standard.integer(forKey: "liveTailMaxEntries")
74+ if storedMax > 0 { maxEntries = storedMax }
75+
76+ isRunning = true
77+ isPaused = false
78+ errorMessage = nil
79+ entries = []
80+ seenFingerprints = []
81+ droppedCount = 0
82+ consecutiveErrors = 0
83+ lastPollTime = nil
84+ lastTimestamp = Date()
85+
86+ timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
87+ Task { @MainActor [weak self] in
88+ guard let self, !self.isPaused else { return }
89+ await self.poll(client: client, stream: stream)
90+ }
91+ }
92+ }
93+
94+ func stop() {
95+ timer?.invalidate()
96+ timer = nil
97+ isRunning = false
98+ isPaused = false
99+ }
100+
101+ func togglePause() {
102+ isPaused.toggle()
103+ }
104+
105+ func clear() {
106+ entries = []
107+ seenFingerprints = []
108+ droppedCount = 0
109+ }
110+
111+ private func poll(client: ParseableClient, stream: String) async {
112+ let now = Date()
113+ let queryStart = lastTimestamp
114+ ?? Calendar.current.date(byAdding: .second, value: -30, to: now)
115+ ?? now.addingTimeInterval(-30)
116+ let sql = "SELECT * FROM \(QueryViewModel.escapeSQLIdentifier(stream)) ORDER BY p_timestamp DESC LIMIT 200"
117+
118+ do {
119+ let records = try await client.query(sql: sql, startTime: queryStart, endTime: now)
120+
121+ var newEntries: [LiveTailEntry] = []
122+
123+ for record in records {
124+ let fp = Self.fingerprint(for: record)
125+ guard !seenFingerprints.contains(fp) else { continue }
126+ seenFingerprints.insert(fp)
127+
128+ let timestamp = parseTimestamp(from: record) ?? now
129+ let summary = buildSummary(from: record)
130+
131+ newEntries.append(LiveTailEntry(
132+ timestamp: timestamp,
133+ record: record,
134+ displayTimestamp: Self.displayFormatter.string(from: timestamp),
135+ summary: summary
136+ ))
137+ }
138+
139+ if !newEntries.isEmpty {
140+ entries.append(contentsOf: newEntries.sorted { $0.timestamp < $1.timestamp })
141+
142+ if entries.count > maxEntries {
143+ let excess = entries.count - maxEntries
144+ droppedCount += excess
145+ entries.removeFirst(excess)
146+
147+ // Rebuild fingerprint set from remaining entries to bound memory
148+ // and ensure dropped entries can be re-detected if they reappear.
149+ seenFingerprints = Set(entries.map { Self.fingerprint(for: $0.record) })
150+ }
151+ }
152+
153+ lastTimestamp = now
154+ lastPollTime = now
155+ errorMessage = nil
156+ consecutiveErrors = 0
157+ } catch {
158+ consecutiveErrors += 1
159+ if consecutiveErrors >= Self.maxConsecutiveErrors {
160+ errorMessage = "\(ParseableError.userFriendlyMessage(for: error)) — stopped after \(consecutiveErrors) consecutive failures"
161+ stop()
162+ } else {
163+ errorMessage = ParseableError.userFriendlyMessage(for: error)
164+ }
165+ }
166+ }
167+
168+ /// Deterministic content-based fingerprint using sorted key-value pairs and FNV-1a.
169+ static func fingerprint(for record: LogRecord) -> String {
170+ var h0: UInt64 = 14695981039346656037
171+ var h1: UInt64 = 14695981039346656037 &* 31
172+ for key in record.keys.sorted() {
173+ for byte in key.utf8 {
174+ h0 = (h0 ^ UInt64(byte)) &* 1099511628211
175+ h1 = (h1 ^ UInt64(byte)) &* 6700417
176+ }
177+ h0 = (h0 ^ 0) &* 1099511628211 // separator
178+ if let val = record[key] {
179+ // Use exportString so nested objects/arrays contribute their
180+ // actual content to the hash, not just their field count.
181+ for byte in val.exportString.utf8 {
182+ h0 = (h0 ^ UInt64(byte)) &* 1099511628211
183+ h1 = (h1 ^ UInt64(byte)) &* 6700417
184+ }
185+ }
186+ h1 = (h1 ^ 0xFF) &* 6700417 // field separator
187+ }
188+ return String(h0, radix: 36) + String(h1, radix: 36)
189+ }
190+
191+ func parseTimestamp(from record: LogRecord) -> Date? {
192+ guard let value = record["p_timestamp"] ?? record["timestamp"] ?? record["time"] ?? record["@timestamp"] else {
193+ return nil
194+ }
195+ if case .string(let str) = value {
196+ if let date = Self.isoFractional.date(from: str) {
197+ return date
198+ }
199+ return Self.isoBasic.date(from: str)
200+ }
201+ return nil
202+ }
203+
204+ func buildSummary(from record: LogRecord) -> String {
205+ var parts: [String] = []
206+
207+ if let level = record["level"] ?? record["severity"] ?? record["log_level"] {
208+ parts.append("[\(level.displayString)]")
209+ }
210+
211+ if let msg = record["message"] ?? record["msg"] ?? record["body"] ?? record["log"] {
212+ parts.append(msg.displayString)
213+ }
214+
215+ if parts.isEmpty {
216+ let scalarFields = record
217+ .filter { $0.key != "p_timestamp" && $0.key != "p_tags" && $0.key != "p_metadata" }
218+ .sorted { $0.key < $1.key }
219+ .prefix(3)
220+ for (key, value) in scalarFields {
221+ if value.isScalar {
222+ parts.append("\(key)=\(value.displayString)")
223+ }
224+ }
225+ }
226+
227+ return parts.joined(separator: " ")
228+ }
229+}
ParseableViewer/ViewModels/QueryViewModel.swiftnew file+330
@@ -0,0 +1,330 @@
1+import Foundation
2+import SwiftUI
3+
4+@Observable
5+final class QueryViewModel {
6+ var sqlQuery = ""
7+ var results: [LogRecord] = [] {
8+ didSet { updateFilteredResults() }
9+ }
10+ var columns: [String] = []
11+ var isLoading = false
12+ var errorMessage: String?
13+ var resultCount = 0
14+ var queryDuration: TimeInterval?
15+ var selectedLogEntry: LogRecord?
16+ var filterText = "" {
17+ didSet { updateFilteredResults() }
18+ }
19+ var resultsTruncated = false
20+
21+ // Query task for cancellation
22+ private var queryTask: Task<Void, Never>?
23+
24+ // Time range
25+ var timeRangeOption: TimeRangeOption = .last1Hour
26+ var customStartDate = Calendar.current.date(byAdding: .hour, value: -1, to: Date()) ?? Date()
27+ var customEndDate = Date()
28+
29+ // Query history
30+ var queryHistory: [QueryHistoryEntry] = []
31+ var historyIsFull = false
32+ private static let maxHistory = 50
33+
34+ struct QueryHistoryEntry: Identifiable, Codable, Sendable {
35+ let id: UUID
36+ let sql: String
37+ let executedAt: Date
38+ let resultCount: Int
39+ let duration: TimeInterval
40+
41+ init(sql: String, resultCount: Int, duration: TimeInterval) {
42+ self.id = UUID()
43+ self.sql = sql
44+ self.executedAt = Date()
45+ self.resultCount = resultCount
46+ self.duration = duration
47+ }
48+ }
49+
50+ enum TimeRangeOption: String, CaseIterable, Identifiable {
51+ case last5Min = "Last 5 minutes"
52+ case last15Min = "Last 15 minutes"
53+ case last30Min = "Last 30 minutes"
54+ case last1Hour = "Last 1 hour"
55+ case last6Hours = "Last 6 hours"
56+ case last24Hours = "Last 24 hours"
57+ case last7Days = "Last 7 days"
58+ case last30Days = "Last 30 days"
59+ case custom = "Custom"
60+
61+ var id: String { rawValue }
62+
63+ /// Key used in UserDefaults / SettingsView @AppStorage for persistence.
64+ var settingsKey: String {
65+ switch self {
66+ case .last5Min: return "last5Min"
67+ case .last15Min: return "last15Min"
68+ case .last30Min: return "last30Min"
69+ case .last1Hour: return "last1Hour"
70+ case .last6Hours: return "last6Hours"
71+ case .last24Hours: return "last24Hours"
72+ case .last7Days: return "last7Days"
73+ case .last30Days: return "last30Days"
74+ case .custom: return "custom"
75+ }
76+ }
77+
78+ func dateRange() -> (start: Date, end: Date) {
79+ let now = Date()
80+ let cal = Calendar.current
81+ let start: Date
82+ switch self {
83+ case .last5Min: start = cal.date(byAdding: .minute, value: -5, to: now) ?? now
84+ case .last15Min: start = cal.date(byAdding: .minute, value: -15, to: now) ?? now
85+ case .last30Min: start = cal.date(byAdding: .minute, value: -30, to: now) ?? now
86+ case .last1Hour: start = cal.date(byAdding: .hour, value: -1, to: now) ?? now
87+ case .last6Hours: start = cal.date(byAdding: .hour, value: -6, to: now) ?? now
88+ case .last24Hours: start = cal.date(byAdding: .hour, value: -24, to: now) ?? now
89+ case .last7Days: start = cal.date(byAdding: .day, value: -7, to: now) ?? now
90+ case .last30Days: start = cal.date(byAdding: .day, value: -30, to: now) ?? now
91+ case .custom: start = now
92+ }
93+ return (start, now)
94+ }
95+ }
96+
97+ var startDate: Date {
98+ if timeRangeOption == .custom {
99+ return customStartDate
100+ }
101+ return timeRangeOption.dateRange().start
102+ }
103+
104+ var endDate: Date {
105+ if timeRangeOption == .custom {
106+ return customEndDate
107+ }
108+ return timeRangeOption.dateRange().end
109+ }
110+
111+ private(set) var filteredResults: [LogRecord] = []
112+
113+ private func updateFilteredResults() {
114+ if filterText.isEmpty {
115+ filteredResults = results
116+ } else {
117+ filteredResults = results.filter { record in
118+ record.values.contains { value in
119+ value.displayString.localizedCaseInsensitiveContains(filterText)
120+ }
121+ }
122+ }
123+ }
124+
125+ private var queryLimit: Int {
126+ let stored = UserDefaults.standard.integer(forKey: "maxQueryResults")
127+ guard stored > 0 else { return 1000 }
128+ return min(stored, 100_000)
129+ }
130+
131+ init() {
132+ queryHistory = Self.loadHistory()
133+ // Apply the default time range from settings
134+ if let stored = UserDefaults.standard.string(forKey: "defaultTimeRange"),
135+ let option = TimeRangeOption.allCases.first(where: { $0.settingsKey == stored }) {
136+ timeRangeOption = option
137+ }
138+ }
139+
140+ @MainActor
141+ func executeQuery(client: ParseableClient?, stream: String?) async {
142+ guard let client else {
143+ errorMessage = "Not connected to server"
144+ return
145+ }
146+
147+ let limit = queryLimit
148+ let sql: String
149+ let usedAutoLimit: Bool
150+ if sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
151+ guard let stream else {
152+ errorMessage = "Select a stream or enter a SQL query"
153+ return
154+ }
155+ sql = "SELECT * FROM \(Self.escapeSQLIdentifier(stream)) ORDER BY p_timestamp DESC LIMIT \(limit)"
156+ usedAutoLimit = true
157+ } else {
158+ sql = sqlQuery
159+ usedAutoLimit = false
160+ }
161+
162+ // Cancel any in-flight query
163+ queryTask?.cancel()
164+
165+ isLoading = true
166+ errorMessage = nil
167+ resultsTruncated = false
168+ let startTime = CFAbsoluteTimeGetCurrent()
169+
170+ let task = Task {
171+ do {
172+ try Task.checkCancellation()
173+ let queryResults = try await client.query(sql: sql, startTime: startDate, endTime: endDate)
174+ try Task.checkCancellation()
175+
176+ results = queryResults
177+ queryDuration = CFAbsoluteTimeGetCurrent() - startTime
178+ resultCount = results.count
179+
180+ // Detect if results were likely truncated (only meaningful
181+ // for auto-generated queries where we control the LIMIT)
182+ resultsTruncated = usedAutoLimit && results.count == limit
183+
184+ // Extract columns in a single pass, with priority fields first
185+ columns = extractColumns(from: results)
186+
187+ // Record in history
188+ let entry = QueryHistoryEntry(sql: sql, resultCount: resultCount, duration: queryDuration ?? 0)
189+ addToHistory(entry)
190+ } catch is CancellationError {
191+ errorMessage = "Query cancelled"
192+ results = []
193+ columns = []
194+ } catch {
195+ errorMessage = ParseableError.userFriendlyMessage(for: error)
196+ results = []
197+ columns = []
198+ }
199+
200+ isLoading = false
201+ }
202+ queryTask = task
203+ await task.value
204+ }
205+
206+ @MainActor
207+ func cancelQuery() {
208+ queryTask?.cancel()
209+ queryTask = nil
210+ isLoading = false
211+ errorMessage = "Query cancelled"
212+ }
213+
214+ /// Single-pass column extraction with priority ordering.
215+ func extractColumns(from records: [LogRecord]) -> [String] {
216+ let priorityFields = ["p_timestamp", "p_tags", "p_metadata", "level", "severity", "message", "msg"]
217+ var allKeys = Set<String>()
218+ for record in records {
219+ allKeys.formUnion(record.keys)
220+ }
221+
222+ var ordered: [String] = []
223+ for field in priorityFields {
224+ if allKeys.remove(field) != nil {
225+ ordered.append(field)
226+ }
227+ }
228+ ordered.append(contentsOf: allKeys.sorted())
229+ return ordered
230+ }
231+
232+ func exportAsJSON() -> String {
233+ guard !results.isEmpty else { return "[]" }
234+ let encoder = JSONEncoder()
235+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
236+ if let data = try? encoder.encode(results),
237+ let string = String(data: data, encoding: .utf8) {
238+ return string
239+ }
240+ return "[]"
241+ }
242+
243+ func exportAsCSV() -> String {
244+ Self.buildCSV(records: results, columns: columns)
245+ }
246+
247+ /// Builds a CSV string from records and columns. Safe to call from any thread.
248+ static func buildCSV(records: [LogRecord], columns: [String]) -> String {
249+ guard !records.isEmpty, !columns.isEmpty else { return "" }
250+
251+ var csv = columns.map { escapeCSV($0) }.joined(separator: ",") + "\n"
252+
253+ for record in records {
254+ let row = columns.map { column in
255+ let value = record[column]?.exportString ?? ""
256+ return escapeCSV(value)
257+ }
258+ csv += row.joined(separator: ",") + "\n"
259+ }
260+
261+ return csv
262+ }
263+
264+ private static func escapeCSV(_ value: String) -> String {
265+ // Check at the Unicode scalar level to correctly detect \r and \n
266+ // inside \r\n (CRLF) grapheme clusters, which Swift's String.contains
267+ // treats as a single Character that doesn't match "\r" or "\n" alone.
268+ let needsQuoting = value.unicodeScalars.contains { scalar in
269+ scalar == "," || scalar == "\"" || scalar == "\n" || scalar == "\r"
270+ }
271+ if needsQuoting {
272+ return "\"\(value.replacingOccurrences(of: "\"", with: "\"\""))\""
273+ }
274+ return value
275+ }
276+
277+ func setDefaultQuery(stream: String, previousStream: String? = nil) {
278+ // Replace the query if it's empty or still matches the auto-generated
279+ // default for the previous stream. If the user edited the SQL, keep it.
280+ let shouldReplace: Bool
281+ if sqlQuery.isEmpty {
282+ shouldReplace = true
283+ } else if let prev = previousStream {
284+ let prevPrefix = "SELECT * FROM \(Self.escapeSQLIdentifier(prev))"
285+ shouldReplace = sqlQuery.hasPrefix(prevPrefix)
286+ } else {
287+ shouldReplace = false
288+ }
289+
290+ if shouldReplace {
291+ sqlQuery = "SELECT * FROM \(Self.escapeSQLIdentifier(stream)) ORDER BY p_timestamp DESC LIMIT \(queryLimit)"
292+ }
293+ }
294+
295+ /// Escapes a SQL identifier by doubling internal double-quotes, then wrapping in double-quotes.
296+ static func escapeSQLIdentifier(_ identifier: String) -> String {
297+ "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\""
298+ }
299+
300+ // MARK: - Query History
301+
302+ private func addToHistory(_ entry: QueryHistoryEntry) {
303+ // Deduplicate consecutive identical queries
304+ if queryHistory.first?.sql == entry.sql { return }
305+ queryHistory.insert(entry, at: 0)
306+ if queryHistory.count > Self.maxHistory {
307+ queryHistory = Array(queryHistory.prefix(Self.maxHistory))
308+ historyIsFull = true
309+ }
310+ Self.saveHistory(queryHistory)
311+ }
312+
313+ func clearHistory() {
314+ queryHistory = []
315+ Self.saveHistory([])
316+ }
317+
318+ private static let historyKey = "parseable_query_history"
319+
320+ private static func loadHistory() -> [QueryHistoryEntry] {
321+ guard let data = UserDefaults.standard.data(forKey: historyKey) else { return [] }
322+ return (try? JSONDecoder().decode([QueryHistoryEntry].self, from: data)) ?? []
323+ }
324+
325+ private static func saveHistory(_ history: [QueryHistoryEntry]) {
326+ if let data = try? JSONEncoder().encode(history) {
327+ UserDefaults.standard.set(data, forKey: historyKey)
328+ }
329+ }
330+}
ParseableViewer/Views/AlertsView.swiftnew file+144
@@ -0,0 +1,144 @@
1+import SwiftUI
2+
3+struct AlertsView: View {
4+ @Environment(AppState.self) private var appState
5+ @State private var alertConfig: AlertConfig?
6+ @State private var isLoading = false
7+ @State private var errorMessage: String?
8+
9+ var body: some View {
10+ Group {
11+ if let stream = appState.selectedStream {
12+ VStack(spacing: 0) {
13+ // Header
14+ HStack {
15+ Text("Alerts for \(stream)")
16+ .font(.headline)
17+ Spacer()
18+ Button {
19+ Task { await loadAlerts(stream: stream) }
20+ } label: {
21+ Image(systemName: "arrow.clockwise")
22+ }
23+ .disabled(isLoading)
24+ .accessibilityLabel("Refresh alerts")
25+ }
26+ .padding()
27+
28+ Divider()
29+
30+ if isLoading {
31+ ProgressView()
32+ .frame(maxWidth: .infinity, maxHeight: .infinity)
33+ } else if let error = errorMessage {
34+ VStack(spacing: 8) {
35+ Image(systemName: "exclamationmark.triangle")
36+ .font(.title)
37+ .foregroundStyle(.secondary)
38+ Text(error)
39+ .foregroundStyle(.secondary)
40+ Button("Retry") {
41+ Task { await loadAlerts(stream: stream) }
42+ }
43+ .buttonStyle(.bordered)
44+ }
45+ .frame(maxWidth: .infinity, maxHeight: .infinity)
46+ } else if let alerts = alertConfig?.alerts, !alerts.isEmpty {
47+ List {
48+ ForEach(alerts) { alert in
49+ AlertRowView(alert: alert)
50+ }
51+ }
52+ } else {
53+ VStack(spacing: 8) {
54+ Image(systemName: "bell.slash")
55+ .font(.system(size: 32))
56+ .foregroundStyle(.secondary)
57+ Text("No alerts configured")
58+ .foregroundStyle(.secondary)
59+ Text("Configure alerts via the Parseable API or web UI")
60+ .font(.caption)
61+ .foregroundStyle(.tertiary)
62+ }
63+ .frame(maxWidth: .infinity, maxHeight: .infinity)
64+ }
65+ }
66+ .task(id: stream) {
67+ await loadAlerts(stream: stream)
68+ }
69+ } else {
70+ Text("Select a stream to view alerts")
71+ .foregroundStyle(.secondary)
72+ .frame(maxWidth: .infinity, maxHeight: .infinity)
73+ }
74+ }
75+ }
76+
77+ private func loadAlerts(stream: String) async {
78+ guard let client = appState.client else { return }
79+ isLoading = true
80+ errorMessage = nil
81+ alertConfig = nil
82+
83+ do {
84+ alertConfig = try await client.getAlerts(stream: stream)
85+ } catch {
86+ errorMessage = ParseableError.userFriendlyMessage(for: error)
87+ }
88+
89+ isLoading = false
90+ }
91+}
92+
93+struct AlertRowView: View {
94+ let alert: AlertRule
95+
96+ var body: some View {
97+ VStack(alignment: .leading, spacing: 6) {
98+ HStack {
99+ Image(systemName: "bell.fill")
100+ .foregroundStyle(.orange)
101+ Text(alert.name)
102+ .fontWeight(.medium)
103+ }
104+
105+ if let message = alert.message {
106+ Text(message)
107+ .font(.caption)
108+ .foregroundStyle(.secondary)
109+ }
110+
111+ if let rule = alert.rule {
112+ HStack {
113+ if let type = rule.type {
114+ Label(type, systemImage: "gearshape")
115+ .font(.caption2)
116+ .padding(.horizontal, 6)
117+ .padding(.vertical, 2)
118+ .background(Color.blue.opacity(0.1))
119+ .clipShape(RoundedRectangle(cornerRadius: 4))
120+ }
121+ }
122+ }
123+
124+ if let targets = alert.targets, !targets.isEmpty {
125+ HStack {
126+ Text("Targets:")
127+ .font(.caption2)
128+ .foregroundStyle(.secondary)
129+ ForEach(Array(targets.enumerated()), id: \.offset) { _, target in
130+ if let type = target.type {
131+ Text(type)
132+ .font(.caption2)
133+ .padding(.horizontal, 6)
134+ .padding(.vertical, 2)
135+ .background(Color.green.opacity(0.1))
136+ .clipShape(RoundedRectangle(cornerRadius: 4))
137+ }
138+ }
139+ }
140+ }
141+ }
142+ .padding(.vertical, 4)
143+ }
144+}
ParseableViewer/Views/ConnectionSheet.swiftnew file+206
@@ -0,0 +1,206 @@
1+import SwiftUI
2+
3+struct ConnectionSheet: View {
4+ @Environment(AppState.self) private var appState
5+ @Environment(\.dismiss) private var dismiss
6+
7+ let connection: ServerConnection?
8+
9+ @State private var name = ""
10+ @State private var url = ""
11+ @State private var username = ""
12+ @State private var password = ""
13+ @State private var isTesting = false
14+ @State private var isSaving = false
15+ @State private var testResult: TestResult?
16+ @State private var urlValidationError: String?
17+
18+ enum TestResult {
19+ case success(String)
20+ case failure(String)
21+ }
22+
23+ var isEditing: Bool { connection != nil }
24+
25+ var body: some View {
26+ VStack(spacing: 0) {
27+ // Header
28+ HStack {
29+ Text(isEditing ? "Edit Connection" : "New Connection")
30+ .font(.headline)
31+ Spacer()
32+ }
33+ .padding()
34+
35+ Divider()
36+
37+ // Form
38+ Form {
39+ TextField("Name:", text: $name)
40+ .textFieldStyle(.roundedBorder)
41+ .accessibilityLabel("Connection name")
42+
43+ TextField("Server URL:", text: $url)
44+ .textFieldStyle(.roundedBorder)
45+ .accessibilityLabel("Server URL")
46+ .onChange(of: url) { _, newValue in
47+ urlValidationError = Self.validateURL(newValue)
48+ }
49+
50+ if let urlError = urlValidationError, !url.isEmpty {
51+ Text(urlError)
52+ .font(.caption2)
53+ .foregroundStyle(.red)
54+ }
55+
56+ TextField("Username:", text: $username)
57+ .textFieldStyle(.roundedBorder)
58+ .accessibilityLabel("Username")
59+
60+ SecureField("Password:", text: $password)
61+ .textFieldStyle(.roundedBorder)
62+ .accessibilityLabel("Password")
63+ }
64+ .formStyle(.grouped)
65+ .padding()
66+
67+ // Test result
68+ if let testResult {
69+ HStack {
70+ switch testResult {
71+ case .success(let msg):
72+ Image(systemName: "checkmark.circle.fill")
73+ .foregroundStyle(.green)
74+ Text(msg)
75+ .foregroundStyle(.green)
76+ case .failure(let msg):
77+ Image(systemName: "xmark.circle.fill")
78+ .foregroundStyle(.red)
79+ Text(msg)
80+ .foregroundStyle(.red)
81+ }
82+ Spacer()
83+ }
84+ .font(.caption)
85+ .padding(.horizontal)
86+ .padding(.bottom, 8)
87+ }
88+
89+ Divider()
90+
91+ // Actions
92+ HStack {
93+ if isEditing {
94+ Button("Delete", role: .destructive) {
95+ if let connection {
96+ appState.removeConnection(connection)
97+ }
98+ password = ""
99+ dismiss()
100+ }
101+ .accessibilityLabel("Delete connection")
102+ }
103+
104+ Spacer()
105+
106+ Button("Test Connection") {
107+ testConnection()
108+ }
109+ .disabled(url.isEmpty || username.isEmpty || isTesting || urlValidationError != nil)
110+ .accessibilityLabel("Test connection")
111+
112+ Button("Cancel") {
113+ password = ""
114+ dismiss()
115+ }
116+ .keyboardShortcut(.cancelAction)
117+
118+ Button(isEditing ? "Save" : "Connect") {
119+ saveAndConnect()
120+ }
121+ .keyboardShortcut(.defaultAction)
122+ .disabled(name.isEmpty || url.isEmpty || username.isEmpty || isSaving || urlValidationError != nil)
123+ }
124+ .padding()
125+ }
126+ .frame(width: 450)
127+ .onAppear {
128+ if let connection {
129+ name = connection.name
130+ url = connection.url
131+ username = connection.username
132+ password = connection.password
133+ }
134+ }
135+ }
136+
137+ private func testConnection() {
138+ isTesting = true
139+ testResult = nil
140+
141+ Task {
142+ do {
143+ let conn = ServerConnection(name: name, url: url, username: username, password: password)
144+ let client = try ParseableClient(connection: conn)
145+ try await client.checkHealth()
146+ await MainActor.run {
147+ testResult = .success("Connection successful")
148+ isTesting = false
149+ }
150+ } catch {
151+ await MainActor.run {
152+ testResult = .failure(ParseableError.userFriendlyMessage(for: error))
153+ isTesting = false
154+ }
155+ }
156+ }
157+ }
158+
159+ private func saveAndConnect() {
160+ guard !isSaving else { return }
161+
162+ let conn: ServerConnection
163+ if let existing = connection {
164+ conn = ServerConnection(id: existing.id, name: name, url: url, username: username, password: password)
165+ appState.updateConnection(conn)
166+ } else {
167+ conn = ServerConnection(name: name, url: url, username: username, password: password)
168+ appState.addConnection(conn)
169+ }
170+
171+ isSaving = true
172+ testResult = nil
173+
174+ Task {
175+ await appState.connect(to: conn)
176+ await MainActor.run {
177+ isSaving = false
178+ if appState.errorMessage != nil {
179+ testResult = .failure(appState.errorMessage ?? "Connection failed")
180+ } else {
181+ password = ""
182+ dismiss()
183+ }
184+ }
185+ }
186+ }
187+
188+ private static func validateURL(_ urlString: String) -> String? {
189+ let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
190+ guard !trimmed.isEmpty else { return nil }
191+
192+ var normalized = trimmed
193+ if !normalized.hasPrefix("http://") && !normalized.hasPrefix("https://") {
194+ normalized = "https://\(normalized)"
195+ }
196+
197+ guard let url = URL(string: normalized),
198+ url.host != nil else {
199+ return "Invalid URL format"
200+ }
201+ guard let scheme = url.scheme, ["http", "https"].contains(scheme.lowercased()) else {
202+ return "URL must use HTTP or HTTPS"
203+ }
204+ return nil
205+ }
206+}
ParseableViewer/Views/ContentView.swiftnew file+180
@@ -0,0 +1,180 @@
1+import SwiftUI
2+
3+struct ContentView: View {
4+ @Environment(AppState.self) private var appState
5+
6+ var body: some View {
7+ @Bindable var appState = appState
8+
9+ NavigationSplitView {
10+ SidebarView()
11+ } detail: {
12+ if appState.isConnected {
13+ if appState.selectedStream != nil {
14+ MainContentView()
15+ } else {
16+ VStack(spacing: 12) {
17+ Image(systemName: "sidebar.left")
18+ .font(.system(size: 48))
19+ .foregroundStyle(.secondary)
20+ Text("Select a stream")
21+ .font(.title2)
22+ .foregroundStyle(.secondary)
23+ Text("Choose a log stream from the sidebar to get started.")
24+ .foregroundStyle(.tertiary)
25+ }
26+ .frame(maxWidth: .infinity, maxHeight: .infinity)
27+ }
28+ } else {
29+ WelcomeView()
30+ }
31+ }
32+ .sheet(isPresented: $appState.showConnectionSheet) {
33+ ConnectionSheet(connection: appState.editingConnection)
34+ }
35+ .alert("Error", isPresented: $appState.showError) {
36+ Button("OK") { appState.showError = false }
37+ } message: {
38+ Text(appState.errorMessage ?? "An unknown error occurred")
39+ }
40+ }
41+}
42+
43+struct MainContentView: View {
44+ @Environment(AppState.self) private var appState
45+
46+ var body: some View {
47+ @Bindable var appState = appState
48+
49+ VStack(spacing: 0) {
50+ // Tab bar
51+ HStack(spacing: 0) {
52+ ForEach(AppState.AppTab.allCases) { tab in
53+ TabButton(
54+ title: tab.rawValue,
55+ icon: tab.systemImage,
56+ isSelected: appState.currentTab == tab
57+ ) {
58+ appState.currentTab = tab
59+ }
60+ }
61+ Spacer()
62+ }
63+ .padding(.horizontal, 8)
64+ .padding(.top, 4)
65+ .background(.bar)
66+
67+ Divider()
68+
69+ // Content — only the active tab is instantiated
70+ switch appState.currentTab {
71+ case .query:
72+ QueryView()
73+ .id(AppState.AppTab.query)
74+ case .liveTail:
75+ LiveTailView()
76+ .id(AppState.AppTab.liveTail)
77+ case .streamInfo:
78+ StreamDetailView()
79+ .id(AppState.AppTab.streamInfo)
80+ case .alerts:
81+ AlertsView()
82+ .id(AppState.AppTab.alerts)
83+ case .users:
84+ UsersView()
85+ .id(AppState.AppTab.users)
86+ case .serverInfo:
87+ ServerInfoView()
88+ .id(AppState.AppTab.serverInfo)
89+ }
90+ }
91+ }
92+}
93+
94+struct TabButton: View {
95+ let title: String
96+ let icon: String
97+ let isSelected: Bool
98+ let action: () -> Void
99+
100+ var body: some View {
101+ Button(action: action) {
102+ HStack(spacing: 4) {
103+ Image(systemName: icon)
104+ .font(.caption)
105+ Text(title)
106+ .font(.caption)
107+ }
108+ .padding(.horizontal, 10)
109+ .padding(.vertical, 5)
110+ .background(isSelected ? Color.accentColor.opacity(0.15) : Color.clear)
111+ .foregroundStyle(isSelected ? Color.accentColor : .secondary)
112+ .clipShape(RoundedRectangle(cornerRadius: 6))
113+ }
114+ .buttonStyle(.plain)
115+ .accessibilityLabel(title)
116+ .accessibilityAddTraits(isSelected ? [.isSelected] : [])
117+ }
118+}
119+
120+struct WelcomeView: View {
121+ @Environment(AppState.self) private var appState
122+
123+ var body: some View {
124+ VStack(spacing: 20) {
125+ Image(systemName: "server.rack")
126+ .font(.system(size: 56))
127+ .foregroundStyle(.secondary)
128+
129+ Text("Parseable Viewer")
130+ .font(.largeTitle)
131+ .fontWeight(.semibold)
132+
133+ Text("Connect to a Parseable server to view and query your logs.")
134+ .foregroundStyle(.secondary)
135+ .multilineTextAlignment(.center)
136+
137+ Button("Add Connection") {
138+ appState.editingConnection = nil
139+ appState.showConnectionSheet = true
140+ }
141+ .buttonStyle(.borderedProminent)
142+ .controlSize(.large)
143+
144+ if !appState.connections.isEmpty {
145+ Divider()
146+ .frame(width: 200)
147+
148+ Text("Recent Connections")
149+ .font(.headline)
150+ .foregroundStyle(.secondary)
151+
152+ ForEach(appState.connections.prefix(5)) { connection in
153+ Button {
154+ Task {
155+ await appState.connect(to: connection)
156+ }
157+ } label: {
158+ HStack {
159+ Image(systemName: "server.rack")
160+ VStack(alignment: .leading) {
161+ Text(connection.name)
162+ .fontWeight(.medium)
163+ Text(connection.url)
164+ .font(.caption)
165+ .foregroundStyle(.secondary)
166+ }
167+ }
168+ .frame(width: 250, alignment: .leading)
169+ }
170+ .buttonStyle(.plain)
171+ .padding(.vertical, 4)
172+ .padding(.horizontal, 12)
173+ .background(Color.primary.opacity(0.04))
174+ .clipShape(RoundedRectangle(cornerRadius: 8))
175+ }
176+ }
177+ }
178+ .frame(maxWidth: .infinity, maxHeight: .infinity)
179+ }
180+}
ParseableViewer/Views/LiveTailView.swiftnew file+202
@@ -0,0 +1,202 @@
1+import SwiftUI
2+
3+struct LiveTailView: View {
4+ @Environment(AppState.self) private var appState
5+ @State private var viewModel = LiveTailViewModel()
6+ @State private var selectedEntry: LiveTailViewModel.LiveTailEntry?
7+ @State private var autoScroll = true
8+ @State private var pulseOpacity: Double = 1.0
9+
10+ var body: some View {
11+ @Bindable var vm = viewModel
12+
13+ HSplitView {
14+ VStack(spacing: 0) {
15+ // Toolbar
16+ HStack {
17+ if viewModel.isRunning {
18+ Button {
19+ viewModel.stop()
20+ } label: {
21+ HStack(spacing: 4) {
22+ Image(systemName: "stop.fill")
23+ Text("Stop")
24+ }
25+ }
26+ .buttonStyle(.bordered)
27+ .tint(.red)
28+ .accessibilityLabel("Stop live tail")
29+
30+ Button {
31+ viewModel.togglePause()
32+ } label: {
33+ HStack(spacing: 4) {
34+ Image(systemName: viewModel.isPaused ? "play.fill" : "pause.fill")
35+ Text(viewModel.isPaused ? "Resume" : "Pause")
36+ }
37+ }
38+ .keyboardShortcut("p", modifiers: .command)
39+ .accessibilityLabel(viewModel.isPaused ? "Resume live tail" : "Pause live tail")
40+ } else {
41+ Button {
42+ viewModel.start(
43+ client: appState.client,
44+ stream: appState.selectedStream
45+ )
46+ } label: {
47+ HStack(spacing: 4) {
48+ Image(systemName: "antenna.radiowaves.left.and.right")
49+ Text("Start Live Tail")
50+ }
51+ }
52+ .buttonStyle(.borderedProminent)
53+ .disabled(!appState.isConnected || appState.selectedStream == nil)
54+ .accessibilityLabel("Start live tail")
55+ }
56+
57+ Spacer()
58+
59+ // Filter
60+ HStack {
61+ Image(systemName: "line.3.horizontal.decrease")
62+ .foregroundStyle(.secondary)
63+ TextField("Filter...", text: $vm.filterText)
64+ .textFieldStyle(.roundedBorder)
65+ .frame(width: 200)
66+ .accessibilityLabel("Filter live tail entries")
67+ }
68+
69+ Toggle("Auto-scroll", isOn: $autoScroll)
70+ .toggleStyle(.checkbox)
71+
72+ Button {
73+ viewModel.clear()
74+ } label: {
75+ Image(systemName: "trash")
76+ }
77+ .help("Clear entries")
78+ .keyboardShortcut(.delete, modifiers: .command)
79+ .accessibilityLabel("Clear all entries")
80+ }
81+ .padding(8)
82+
83+ Divider()
84+
85+ // Status bar
86+ HStack {
87+ if viewModel.isRunning {
88+ Circle()
89+ .fill(viewModel.isPaused ? .yellow : .green)
90+ .frame(width: 8, height: 8)
91+ .opacity(viewModel.isPaused ? 1.0 : pulseOpacity)
92+ .accessibilityLabel(viewModel.isPaused ? "Status: Paused" : "Status: Streaming")
93+ .animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: pulseOpacity)
94+ .onAppear { pulseOpacity = 0.3 }
95+ .onDisappear { pulseOpacity = 1.0 }
96+ Text(viewModel.isPaused ? "Paused" : "Streaming")
97+ .font(.caption)
98+ .foregroundStyle(.secondary)
99+ }
100+
101+ if let error = viewModel.errorMessage {
102+ Image(systemName: "exclamationmark.triangle")
103+ .foregroundStyle(.yellow)
104+ Text(error)
105+ .font(.caption)
106+ .foregroundStyle(.yellow)
107+ .lineLimit(1)
108+ }
109+
110+ Spacer()
111+
112+ if viewModel.droppedCount > 0 {
113+ Text("\(viewModel.droppedCount) dropped")
114+ .font(.caption)
115+ .foregroundStyle(.orange)
116+ .help("Oldest entries were removed to stay within the \(viewModel.maxEntries)-entry buffer limit")
117+ }
118+
119+ Text("\(viewModel.displayedCount) entries")
120+ .font(.caption)
121+ .foregroundStyle(.secondary)
122+
123+ if !viewModel.filterText.isEmpty && viewModel.displayedCount != viewModel.entryCount {
124+ Text("(of \(viewModel.entryCount) total)")
125+ .font(.caption)
126+ .foregroundStyle(.secondary)
127+ }
128+ }
129+ .padding(.horizontal, 8)
130+ .padding(.vertical, 4)
131+ .background(.bar)
132+
133+ Divider()
134+
135+ // Entries list
136+ ScrollViewReader { proxy in
137+ List(viewModel.filteredEntries, selection: Binding(
138+ get: { selectedEntry?.id },
139+ set: { id in
140+ selectedEntry = viewModel.filteredEntries.first { $0.id == id }
141+ }
142+ )) { entry in
143+ LiveTailEntryRow(entry: entry)
144+ .id(entry.id)
145+ .tag(entry.id)
146+ }
147+ .listStyle(.plain)
148+ .font(.system(.caption, design: .monospaced))
149+ .onChange(of: viewModel.entries.count) { _, _ in
150+ guard autoScroll else { return }
151+ // When a filter is active, scroll to the last filtered entry;
152+ // when no filter, scroll to the absolute last entry.
153+ let target = viewModel.filterText.isEmpty
154+ ? viewModel.entries.last
155+ : viewModel.filteredEntries.last
156+ if let target {
157+ proxy.scrollTo(target.id, anchor: .bottom)
158+ }
159+ }
160+ }
161+ }
162+
163+ // Detail pane
164+ if let entry = selectedEntry {
165+ LogDetailView(record: entry.record)
166+ .frame(minWidth: 280, idealWidth: 320)
167+ }
168+ }
169+ .onDisappear {
170+ viewModel.stop()
171+ }
172+ .onChange(of: appState.selectedStream) { _, _ in
173+ if viewModel.isRunning {
174+ viewModel.stop()
175+ }
176+ }
177+ }
178+}
179+
180+struct LiveTailEntryRow: View {
181+ let entry: LiveTailViewModel.LiveTailEntry
182+
183+ var body: some View {
184+ HStack(spacing: 8) {
185+ Text(entry.displayTimestamp)
186+ .foregroundStyle(.secondary)
187+ .frame(width: 90, alignment: .leading)
188+
189+ if let level = entry.record["level"] ?? entry.record["severity"] {
190+ Text(level.displayString)
191+ .fontWeight(.medium)
192+ .foregroundStyle(levelColor(for: level.displayString))
193+ .frame(width: 50, alignment: .leading)
194+ }
195+
196+ Text(entry.summary)
197+ .lineLimit(1)
198+ .frame(maxWidth: .infinity, alignment: .leading)
199+ .help(entry.summary)
200+ }
201+ }
202+}
ParseableViewer/Views/LogDetailView.swiftnew file+201
@@ -0,0 +1,201 @@
1+import SwiftUI
2+
3+struct LogDetailView: View {
4+ let record: LogRecord
5+ @State private var viewMode: ViewMode = .formatted
6+ @State private var showCopyConfirmation = false
7+
8+ enum ViewMode: String, CaseIterable {
9+ case formatted = "Formatted"
10+ case raw = "Raw JSON"
11+ }
12+
13+ var body: some View {
14+ VStack(spacing: 0) {
15+ // Header
16+ HStack {
17+ Text("Log Entry")
18+ .font(.headline)
19+ Spacer()
20+ Picker("View", selection: $viewMode) {
21+ ForEach(ViewMode.allCases, id: \.self) { mode in
22+ Text(mode.rawValue).tag(mode)
23+ }
24+ }
25+ .pickerStyle(.segmented)
26+ .frame(width: 200)
27+
28+ Button {
29+ copyToClipboard()
30+ } label: {
31+ Image(systemName: showCopyConfirmation ? "checkmark" : "doc.on.doc")
32+ .foregroundStyle(showCopyConfirmation ? .green : .primary)
33+ }
34+ .help("Copy to clipboard")
35+ .accessibilityLabel(showCopyConfirmation ? "Copied to clipboard" : "Copy log entry to clipboard")
36+ }
37+ .padding(8)
38+
39+ Divider()
40+
41+ // Content
42+ ScrollView {
43+ switch viewMode {
44+ case .formatted:
45+ FormattedRecordView(record: record)
46+ case .raw:
47+ RawJSONView(record: record)
48+ }
49+ }
50+ }
51+ .background(Color(nsColor: .controlBackgroundColor))
52+ }
53+
54+ private func copyToClipboard() {
55+ let encoder = JSONEncoder()
56+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
57+ if let data = try? encoder.encode(record),
58+ let json = String(data: data, encoding: .utf8) {
59+ NSPasteboard.general.clearContents()
60+ let success = NSPasteboard.general.setString(json, forType: .string)
61+ guard success else { return }
62+
63+ showCopyConfirmation = true
64+ Task {
65+ try? await Task.sleep(for: .seconds(1.5))
66+ showCopyConfirmation = false
67+ }
68+ }
69+ }
70+}
71+
72+struct FormattedRecordView: View {
73+ let record: LogRecord
74+
75+ var sortedKeys: [String] {
76+ let priority = ["p_timestamp", "level", "severity", "message", "msg"]
77+ var keys = record.keys.sorted()
78+ var result: [String] = []
79+
80+ for p in priority {
81+ if let idx = keys.firstIndex(of: p) {
82+ result.append(keys.remove(at: idx))
83+ }
84+ }
85+ result.append(contentsOf: keys)
86+ return result
87+ }
88+
89+ var body: some View {
90+ VStack(alignment: .leading, spacing: 1) {
91+ ForEach(sortedKeys, id: \.self) { key in
92+ HStack(alignment: .top, spacing: 8) {
93+ Text(key)
94+ .font(.system(.caption, design: .monospaced))
95+ .fontWeight(.semibold)
96+ .foregroundStyle(.secondary)
97+ .frame(width: 120, alignment: .trailing)
98+
99+ if let value = record[key] {
100+ JSONValueView(value: value)
101+ }
102+
103+ Spacer()
104+ }
105+ .padding(.vertical, 3)
106+ .padding(.horizontal, 8)
107+ .background(
108+ sortedKeys.firstIndex(of: key).map { $0 % 2 == 1 }
109+ ?? false ? Color.primary.opacity(0.03) : Color.clear
110+ )
111+ }
112+ }
113+ .padding(.vertical, 4)
114+ }
115+}
116+
117+struct JSONValueView: View {
118+ let value: JSONValue
119+ var depth: Int = 0
120+ private static let maxDepth = 10
121+
122+ var body: some View {
123+ if depth >= Self.maxDepth {
124+ Text(value.displayString)
125+ .font(.system(.caption, design: .monospaced))
126+ .foregroundStyle(.secondary)
127+ .italic()
128+ .help("Nesting too deep to display inline")
129+ } else {
130+ content
131+ }
132+ }
133+
134+ @ViewBuilder
135+ private var content: some View {
136+ switch value {
137+ case .null:
138+ Text("null")
139+ .font(.system(.caption, design: .monospaced))
140+ .foregroundStyle(.secondary)
141+ case .bool(let v):
142+ Text(v ? "true" : "false")
143+ .font(.system(.caption, design: .monospaced))
144+ .foregroundStyle(.purple)
145+ case .int(let v):
146+ Text(String(v))
147+ .font(.system(.caption, design: .monospaced))
148+ .foregroundStyle(.blue)
149+ case .double(let v):
150+ Text(String(v))
151+ .font(.system(.caption, design: .monospaced))
152+ .foregroundStyle(.blue)
153+ case .string(let v):
154+ Text(v)
155+ .font(.system(.caption, design: .monospaced))
156+ .foregroundStyle(.primary)
157+ .textSelection(.enabled)
158+ case .array(let items):
159+ VStack(alignment: .leading, spacing: 2) {
160+ ForEach(Array(items.enumerated()), id: \.offset) { idx, item in
161+ HStack(alignment: .top) {
162+ Text("[\(idx)]")
163+ .font(.system(.caption2, design: .monospaced))
164+ .foregroundStyle(.secondary)
165+ JSONValueView(value: item, depth: depth + 1)
166+ }
167+ }
168+ }
169+ case .object(let dict):
170+ VStack(alignment: .leading, spacing: 2) {
171+ ForEach(dict.keys.sorted(), id: \.self) { key in
172+ HStack(alignment: .top) {
173+ Text("\(key):")
174+ .font(.system(.caption2, design: .monospaced))
175+ .foregroundStyle(.secondary)
176+ if let val = dict[key] {
177+ JSONValueView(value: val, depth: depth + 1)
178+ }
179+ }
180+ }
181+ }
182+ }
183+ }
184+}
185+
186+struct RawJSONView: View {
187+ let record: LogRecord
188+
189+ var jsonString: String {
190+ let obj = JSONValue.object(record)
191+ return obj.prettyPrinted()
192+ }
193+
194+ var body: some View {
195+ Text(jsonString)
196+ .font(.system(.caption, design: .monospaced))
197+ .textSelection(.enabled)
198+ .padding(8)
199+ .frame(maxWidth: .infinity, alignment: .leading)
200+ }
201+}
ParseableViewer/Views/LogTableView.swiftnew file+200
@@ -0,0 +1,200 @@
1+import SwiftUI
2+
3+/// Shared column width logic used by both header and row views.
4+func columnWidth(for column: String) -> CGFloat {
5+ switch column {
6+ case "p_timestamp", "timestamp", "@timestamp", "time":
7+ return 200
8+ case "level", "severity", "log_level":
9+ return 80
10+ case "message", "msg", "body", "log":
11+ return 400
12+ default:
13+ return 160
14+ }
15+}
16+
17+/// Shared log-level color mapping.
18+func levelColor(for value: String) -> Color {
19+ switch value.lowercased() {
20+ case "error", "fatal", "critical", "panic": return .red
21+ case "warn", "warning": return .orange
22+ case "info": return .blue
23+ case "debug", "trace": return .secondary
24+ default: return .primary
25+ }
26+}
27+
28+struct LogTableView: View {
29+ let records: [LogRecord]
30+ let columns: [String]
31+ @Binding var selectedRecord: LogRecord?
32+ @State private var sortColumn: String?
33+ @State private var sortAscending = false
34+ @State private var selectedIndex: Int?
35+ @State private var cachedSorted: [LogRecord] = []
36+ @State private var sortTask: Task<Void, Never>?
37+
38+ var body: some View {
39+ if records.isEmpty {
40+ VStack(spacing: 8) {
41+ Image(systemName: "doc.text.magnifyingglass")
42+ .font(.system(size: 32))
43+ .foregroundStyle(.secondary)
44+ Text("No results")
45+ .foregroundStyle(.secondary)
46+ }
47+ .frame(maxWidth: .infinity, maxHeight: .infinity)
48+ } else {
49+ HSplitView {
50+ ScrollView([.horizontal, .vertical]) {
51+ LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
52+ Section {
53+ ForEach(cachedSorted.indices, id: \.self) { index in
54+ LogRowView(
55+ record: cachedSorted[index],
56+ columns: columns,
57+ isSelected: selectedIndex == index,
58+ isAlternate: index % 2 == 1
59+ )
60+ .onTapGesture {
61+ selectedIndex = index
62+ selectedRecord = cachedSorted[index]
63+ }
64+ }
65+ } header: {
66+ LogHeaderView(
67+ columns: columns,
68+ sortColumn: $sortColumn,
69+ sortAscending: $sortAscending
70+ )
71+ }
72+ }
73+ }
74+ .frame(minWidth: 400)
75+
76+ if let selected = selectedRecord {
77+ LogDetailView(record: selected)
78+ .frame(minWidth: 300, idealWidth: 350)
79+ }
80+ }
81+ .onChange(of: records) { _, _ in debouncedRebuildSort() }
82+ .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
83+ .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
84+ .onAppear { rebuildSort() }
85+ }
86+ }
87+
88+ private func debouncedRebuildSort() {
89+ sortTask?.cancel()
90+ sortTask = Task {
91+ try? await Task.sleep(for: .milliseconds(150))
92+ guard !Task.isCancelled else { return }
93+ rebuildSort()
94+ }
95+ }
96+
97+ private func rebuildSort() {
98+ if let sortColumn {
99+ cachedSorted = records.sorted { a, b in
100+ let aVal = a[sortColumn] ?? .null
101+ let bVal = b[sortColumn] ?? .null
102+ return sortAscending ? aVal < bVal : bVal < aVal
103+ }
104+ } else {
105+ cachedSorted = records
106+ }
107+ if let selectedRecord,
108+ let idx = cachedSorted.firstIndex(where: { $0 == selectedRecord }) {
109+ selectedIndex = idx
110+ } else {
111+ selectedIndex = nil
112+ }
113+ }
114+}
115+
116+struct LogHeaderView: View {
117+ let columns: [String]
118+ @Binding var sortColumn: String?
119+ @Binding var sortAscending: Bool
120+
121+ var body: some View {
122+ HStack(spacing: 0) {
123+ ForEach(columns, id: \.self) { column in
124+ Button {
125+ if sortColumn == column {
126+ sortAscending.toggle()
127+ } else {
128+ sortColumn = column
129+ sortAscending = true
130+ }
131+ } label: {
132+ HStack(spacing: 2) {
133+ Text(column)
134+ .font(.caption.weight(.semibold))
135+ .lineLimit(1)
136+ if sortColumn == column {
137+ Image(systemName: sortAscending ? "chevron.up" : "chevron.down")
138+ .font(.caption2)
139+ }
140+ }
141+ }
142+ .buttonStyle(.plain)
143+ .frame(width: columnWidth(for: column), alignment: .leading)
144+ .padding(.horizontal, 6)
145+ .padding(.vertical, 4)
146+ .accessibilityLabel(sortColumn == column
147+ ? "Sort by \(column), \(sortAscending ? "ascending" : "descending")"
148+ : "Sort by \(column)")
149+ }
150+ }
151+ .background(Color(nsColor: .controlBackgroundColor))
152+ .border(Color.secondary.opacity(0.2), width: 0.5)
153+ }
154+}
155+
156+struct LogRowView: View {
157+ let record: LogRecord
158+ let columns: [String]
159+ let isSelected: Bool
160+ let isAlternate: Bool
161+
162+ var body: some View {
163+ HStack(spacing: 0) {
164+ ForEach(columns, id: \.self) { column in
165+ let value = record[column]
166+ Text(value?.displayString ?? "")
167+ .font(.system(.caption, design: .monospaced))
168+ .lineLimit(1)
169+ .frame(width: columnWidth(for: column), alignment: .leading)
170+ .padding(.horizontal, 6)
171+ .padding(.vertical, 3)
172+ .foregroundStyle(colorForValue(column: column, value: value))
173+ .contextMenu {
174+ Button("Copy Value") {
175+ let text = value?.displayString ?? ""
176+ NSPasteboard.general.clearContents()
177+ _ = NSPasteboard.general.setString(text, forType: .string)
178+ }
179+ Button("Copy Column Name") {
180+ NSPasteboard.general.clearContents()
181+ _ = NSPasteboard.general.setString(column, forType: .string)
182+ }
183+ }
184+ }
185+ }
186+ .background(
187+ isSelected
188+ ? Color.accentColor.opacity(0.2)
189+ : (isAlternate ? Color.primary.opacity(0.02) : Color.clear)
190+ )
191+ }
192+
193+ private func colorForValue(column: String, value: JSONValue?) -> Color {
194+ guard let value else { return .secondary }
195+ if column == "level" || column == "severity" || column == "log_level" {
196+ return levelColor(for: value.displayString)
197+ }
198+ return .primary
199+ }
200+}
ParseableViewer/Views/QueryView.swiftnew file+298
@@ -0,0 +1,298 @@
1+import SwiftUI
2+
3+struct QueryView: View {
4+ @Environment(AppState.self) private var appState
5+ @State private var viewModel = QueryViewModel()
6+ @State private var showSaveQuerySheet = false
7+ @State private var saveQueryName = ""
8+ @State private var exportError: String?
9+ @State private var showExportError = false
10+
11+ var body: some View {
12+ @Bindable var vm = viewModel
13+
14+ VSplitView {
15+ // Query editor panel
16+ VStack(spacing: 0) {
17+ // Toolbar
18+ HStack {
19+ TimeRangePicker(
20+ option: $vm.timeRangeOption,
21+ customStart: $vm.customStartDate,
22+ customEnd: $vm.customEndDate
23+ )
24+
25+ Spacer()
26+
27+ if !viewModel.queryHistory.isEmpty {
28+ Menu {
29+ if viewModel.historyIsFull {
30+ Text("History limit reached (oldest entries removed)")
31+ Divider()
32+ }
33+ ForEach(viewModel.queryHistory.prefix(15)) { entry in
34+ Button {
35+ viewModel.sqlQuery = entry.sql
36+ } label: {
37+ VStack(alignment: .leading) {
38+ Text(entry.sql.prefix(80))
39+ Text("\(entry.resultCount) rows - \(entry.executedAt.formatted(.dateTime.month().day().hour().minute()))")
40+ .font(.caption2)
41+ }
42+ }
43+ }
44+ Divider()
45+ Button("Clear History", role: .destructive) {
46+ viewModel.clearHistory()
47+ }
48+ } label: {
49+ Image(systemName: "clock.arrow.circlepath")
50+ }
51+ .help(viewModel.historyIsFull ? "Query history (full)" : "Query history")
52+ .accessibilityLabel("Query history")
53+ }
54+
55+ Button {
56+ showSaveQuerySheet = true
57+ } label: {
58+ Image(systemName: "bookmark")
59+ }
60+ .help("Save query")
61+ .disabled(viewModel.sqlQuery.isEmpty)
62+ .accessibilityLabel("Save query")
63+
64+ Menu {
65+ Button("Export as JSON") { exportJSON() }
66+ Button("Export as CSV") { exportCSV() }
67+ } label: {
68+ Image(systemName: "square.and.arrow.up")
69+ }
70+ .disabled(viewModel.results.isEmpty)
71+ .accessibilityLabel("Export results")
72+ .help("Export results")
73+
74+ if viewModel.isLoading {
75+ Button {
76+ viewModel.cancelQuery()
77+ } label: {
78+ HStack(spacing: 4) {
79+ ProgressView()
80+ .controlSize(.small)
81+ Text("Cancel")
82+ }
83+ }
84+ .keyboardShortcut(.escape, modifiers: [])
85+ .buttonStyle(.bordered)
86+ .tint(.red)
87+ .accessibilityLabel("Cancel running query")
88+ } else {
89+ Button {
90+ Task {
91+ await viewModel.executeQuery(
92+ client: appState.client,
93+ stream: appState.selectedStream
94+ )
95+ }
96+ } label: {
97+ HStack(spacing: 4) {
98+ Image(systemName: "play.fill")
99+ Text("Run")
100+ }
101+ }
102+ .keyboardShortcut(.return, modifiers: .command)
103+ .buttonStyle(.borderedProminent)
104+ }
105+ }
106+ .padding(8)
107+
108+ // SQL editor
109+ TextEditor(text: $vm.sqlQuery)
110+ .font(.system(.body, design: .monospaced))
111+ .frame(minHeight: 60, maxHeight: 120)
112+ .scrollContentBackground(.hidden)
113+ .padding(4)
114+ .background(Color(nsColor: .textBackgroundColor))
115+ .clipShape(RoundedRectangle(cornerRadius: 4))
116+ .overlay(
117+ RoundedRectangle(cornerRadius: 4)
118+ .stroke(Color.secondary.opacity(0.2))
119+ )
120+ .padding(.horizontal, 8)
121+ .padding(.bottom, 4)
122+ .accessibilityLabel("SQL query editor")
123+
124+ // Truncation warning
125+ if viewModel.resultsTruncated {
126+ HStack(spacing: 4) {
127+ Image(systemName: "exclamationmark.triangle.fill")
128+ Text("Results may be truncated. Increase the LIMIT or narrow your query to see all data.")
129+ Spacer()
130+ }
131+ .font(.caption)
132+ .foregroundStyle(.orange)
133+ .padding(.horizontal, 8)
134+ .padding(.vertical, 3)
135+ .background(Color.orange.opacity(0.1))
136+ }
137+
138+ // Status bar
139+ HStack {
140+ if let error = viewModel.errorMessage {
141+ Image(systemName: "exclamationmark.triangle")
142+ .foregroundStyle(.red)
143+ Text(error)
144+ .foregroundStyle(.red)
145+ .lineLimit(1)
146+ } else if viewModel.resultCount > 0 {
147+ Text("\(viewModel.resultCount) results")
148+ if let duration = viewModel.queryDuration {
149+ Text("(\(String(format: "%.2fs", duration)))")
150+ .foregroundStyle(.secondary)
151+ }
152+ } else {
153+ Text("Enter a SQL query or press Run to query the selected stream")
154+ .foregroundStyle(.secondary)
155+ }
156+ Spacer()
157+ }
158+ .font(.caption)
159+ .padding(.horizontal, 8)
160+ .padding(.bottom, 4)
161+
162+ Divider()
163+ }
164+ .frame(minHeight: 120)
165+
166+ // Results panel
167+ VStack(spacing: 0) {
168+ // Filter bar
169+ if !viewModel.results.isEmpty {
170+ HStack {
171+ Image(systemName: "line.3.horizontal.decrease")
172+ .foregroundStyle(.secondary)
173+ TextField("Filter results...", text: $vm.filterText)
174+ .textFieldStyle(.roundedBorder)
175+ .frame(maxWidth: 300)
176+ .accessibilityLabel("Filter query results")
177+
178+ if !viewModel.filterText.isEmpty {
179+ Text("\(viewModel.filteredResults.count) of \(viewModel.results.count)")
180+ .font(.caption)
181+ .foregroundStyle(.secondary)
182+ }
183+
184+ Spacer()
185+ }
186+ .padding(6)
187+ }
188+
189+ // Results table
190+ LogTableView(
191+ records: viewModel.filteredResults,
192+ columns: viewModel.columns,
193+ selectedRecord: $vm.selectedLogEntry
194+ )
195+ }
196+ }
197+ .onChange(of: appState.selectedStream) { oldValue, newValue in
198+ if let stream = newValue {
199+ viewModel.setDefaultQuery(stream: stream, previousStream: oldValue)
200+ }
201+ }
202+ .onAppear {
203+ if let stream = appState.selectedStream {
204+ viewModel.setDefaultQuery(stream: stream)
205+ }
206+ }
207+ .onDisappear {
208+ if viewModel.isLoading {
209+ viewModel.cancelQuery()
210+ }
211+ }
212+ .onChange(of: appState.pendingSavedQuerySQL) { _, newSQL in
213+ if let sql = newSQL {
214+ viewModel.sqlQuery = sql
215+ appState.pendingSavedQuerySQL = nil
216+ }
217+ }
218+ .alert("Export Error", isPresented: $showExportError) {
219+ Button("OK") {}
220+ } message: {
221+ Text(exportError ?? "Unknown error")
222+ }
223+ .sheet(isPresented: $showSaveQuerySheet) {
224+ VStack(spacing: 16) {
225+ Text("Save Query")
226+ .font(.headline)
227+ TextField("Query name", text: $saveQueryName)
228+ .textFieldStyle(.roundedBorder)
229+ HStack {
230+ Button("Cancel") { showSaveQuerySheet = false }
231+ Spacer()
232+ Button("Save") {
233+ let query = SavedQuery(
234+ name: saveQueryName,
235+ sql: viewModel.sqlQuery,
236+ stream: appState.selectedStream ?? ""
237+ )
238+ appState.addSavedQuery(query)
239+ saveQueryName = ""
240+ showSaveQuerySheet = false
241+ }
242+ .disabled(saveQueryName.isEmpty)
243+ .buttonStyle(.borderedProminent)
244+ }
245+ }
246+ .padding()
247+ .frame(width: 350)
248+ }
249+ }
250+
251+ private func exportJSON() {
252+ let results = viewModel.results
253+ saveToFile(type: "json") {
254+ let encoder = JSONEncoder()
255+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
256+ do {
257+ let data = try encoder.encode(results)
258+ return String(data: data, encoding: .utf8) ?? "[]"
259+ } catch {
260+ return nil
261+ }
262+ }
263+ }
264+
265+ private func exportCSV() {
266+ let results = viewModel.results
267+ let columns = viewModel.columns
268+ saveToFile(type: "csv") {
269+ QueryViewModel.buildCSV(records: results, columns: columns)
270+ }
271+ }
272+
273+ private func saveToFile(type: String, generate: @Sendable @escaping () -> String?) {
274+ let panel = NSSavePanel()
275+ panel.allowedContentTypes = type == "json" ? [.json] : [.commaSeparatedText]
276+ panel.nameFieldStringValue = "export.\(type)"
277+ panel.begin { result in
278+ guard result == .OK, let url = panel.url else { return }
279+ Task.detached(priority: .userInitiated) {
280+ guard let content = generate() else {
281+ await MainActor.run {
282+ exportError = "Failed to encode data for export"
283+ showExportError = true
284+ }
285+ return
286+ }
287+ do {
288+ try content.write(to: url, atomically: true, encoding: .utf8)
289+ } catch {
290+ await MainActor.run {
291+ exportError = "Export failed: \(error.localizedDescription)"
292+ showExportError = true
293+ }
294+ }
295+ }
296+ }
297+ }
298+}
ParseableViewer/Views/ServerInfoView.swiftnew file+154
@@ -0,0 +1,154 @@
1+import SwiftUI
2+
3+struct ServerInfoView: View {
4+ @Environment(AppState.self) private var appState
5+ @State private var about: ServerAbout?
6+ @State private var isLoading = false
7+ @State private var isHealthy = false
8+ @State private var errorMessage: String?
9+
10+ var body: some View {
11+ ScrollView {
12+ VStack(alignment: .leading, spacing: 16) {
13+ HStack {
14+ Text("Server Information")
15+ .font(.title2)
16+ .fontWeight(.semibold)
17+ Spacer()
18+ Button {
19+ Task { await loadInfo() }
20+ } label: {
21+ Image(systemName: "arrow.clockwise")
22+ }
23+ .disabled(isLoading)
24+ .accessibilityLabel("Refresh server info")
25+ }
26+
27+ if isLoading {
28+ ProgressView()
29+ .frame(maxWidth: .infinity)
30+ }
31+
32+ if let error = errorMessage {
33+ HStack {
34+ Image(systemName: "exclamationmark.triangle")
35+ Text(error)
36+ }
37+ .foregroundStyle(.red)
38+ .font(.caption)
39+ }
40+
41+ // Health status
42+ GroupBox("Health") {
43+ HStack {
44+ Circle()
45+ .fill(isHealthy ? .green : .red)
46+ .frame(width: 12, height: 12)
47+ .accessibilityLabel(isHealthy ? "Healthy" : "Unhealthy")
48+ Text(isHealthy ? "Healthy" : "Unhealthy")
49+ .fontWeight(.medium)
50+ Spacer()
51+ }
52+ .padding(8)
53+ }
54+
55+ if let about = about ?? appState.serverAbout {
56+ GroupBox("Server Details") {
57+ VStack(alignment: .leading, spacing: 8) {
58+ InfoRow(label: "Version", value: about.version ?? "N/A")
59+ InfoRow(label: "UI Version", value: about.uiVersion ?? "N/A")
60+ InfoRow(label: "Commit", value: about.commit ?? "N/A")
61+ InfoRow(label: "Deployment ID", value: about.deploymentId ?? "N/A")
62+ InfoRow(label: "Mode", value: about.mode ?? "N/A")
63+ InfoRow(label: "Staging", value: about.staging ?? "N/A")
64+ InfoRow(label: "License", value: about.license ?? "N/A")
65+ if let port = about.grpcPort {
66+ InfoRow(label: "gRPC Port", value: String(port))
67+ }
68+ if let llm = about.llmActive {
69+ InfoRow(label: "LLM Active", value: llm ? "Yes" : "No")
70+ }
71+ if let oidc = about.oidcActive {
72+ InfoRow(label: "OIDC Active", value: oidc ? "Yes" : "No")
73+ }
74+ if let update = about.updateAvailable, update {
75+ InfoRow(label: "Update Available", value: about.latestVersion ?? "Yes")
76+ }
77+ }
78+ .padding(8)
79+ }
80+
81+ if let store = about.store {
82+ GroupBox("Storage") {
83+ VStack(alignment: .leading, spacing: 8) {
84+ InfoRow(label: "Type", value: store.type ?? "N/A")
85+ InfoRow(label: "Path", value: store.path ?? "N/A")
86+ }
87+ .padding(8)
88+ }
89+ }
90+ }
91+
92+ // Connection info
93+ if let connection = appState.activeConnection {
94+ GroupBox("Connection") {
95+ VStack(alignment: .leading, spacing: 8) {
96+ InfoRow(label: "Name", value: connection.name)
97+ InfoRow(label: "URL", value: connection.url)
98+ InfoRow(label: "Username", value: connection.username)
99+ }
100+ .padding(8)
101+ }
102+ }
103+
104+ // Stream summary
105+ GroupBox("Streams") {
106+ HStack {
107+ Text("\(appState.streams.count) log streams")
108+ .font(.headline)
109+ Spacer()
110+ }
111+ .padding(8)
112+ }
113+
114+ // App version
115+ GroupBox("Parseable Viewer") {
116+ HStack {
117+ let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "–"
118+ let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "–"
119+ InfoRow(label: "App Version", value: "\(appVersion) (\(buildNumber))")
120+ }
121+ .padding(8)
122+ }
123+ }
124+ .padding()
125+ }
126+ .task {
127+ await loadInfo()
128+ }
129+ }
130+
131+ private func loadInfo() async {
132+ guard let client = appState.client else { return }
133+ isLoading = true
134+ errorMessage = nil
135+
136+ async let healthCheck: Void = client.checkHealth()
137+ async let aboutResult = client.getAbout()
138+
139+ do {
140+ try await healthCheck
141+ isHealthy = true
142+ } catch {
143+ isHealthy = false
144+ }
145+ do {
146+ about = try await aboutResult
147+ } catch {
148+ about = nil
149+ errorMessage = ParseableError.userFriendlyMessage(for: error)
150+ }
151+
152+ isLoading = false
153+ }
154+}
ParseableViewer/Views/SettingsView.swiftnew file+104
@@ -0,0 +1,104 @@
1+import SwiftUI
2+
3+struct SettingsView: View {
4+ @Environment(AppState.self) private var appState
5+ @AppStorage("defaultTimeRange") private var defaultTimeRange = "last1Hour"
6+ @AppStorage("maxQueryResults") private var maxQueryResults = 1000
7+ @AppStorage("liveTailPollInterval") private var liveTailPollInterval = 2.0
8+ @AppStorage("liveTailMaxEntries") private var liveTailMaxEntries = 5000
9+ var body: some View {
10+ TabView {
11+ // General settings
12+ Form {
13+ Section("Query Defaults") {
14+ Picker("Default Time Range", selection: $defaultTimeRange) {
15+ Text("Last 5 minutes").tag("last5Min")
16+ Text("Last 15 minutes").tag("last15Min")
17+ Text("Last 30 minutes").tag("last30Min")
18+ Text("Last 1 hour").tag("last1Hour")
19+ Text("Last 6 hours").tag("last6Hours")
20+ Text("Last 24 hours").tag("last24Hours")
21+ Text("Last 7 days").tag("last7Days")
22+ Text("Last 30 days").tag("last30Days")
23+ }
24+
25+ Stepper("Max Results: \(maxQueryResults)",
26+ value: $maxQueryResults,
27+ in: 100...10000,
28+ step: 100)
29+ }
30+
31+ Section("Live Tail") {
32+ HStack {
33+ Text("Poll Interval:")
34+ Slider(value: $liveTailPollInterval, in: 1...10, step: 0.5)
35+ Text("\(String(format: "%.1f", liveTailPollInterval))s")
36+ .frame(width: 40)
37+ }
38+
39+ Stepper("Max Entries: \(liveTailMaxEntries)",
40+ value: $liveTailMaxEntries,
41+ in: 1000...50000,
42+ step: 1000)
43+ }
44+
45+ }
46+ .formStyle(.grouped)
47+ .tabItem {
48+ Label("General", systemImage: "gear")
49+ }
50+
51+ // Connections
52+ VStack {
53+ List {
54+ ForEach(appState.connections) { connection in
55+ HStack {
56+ VStack(alignment: .leading) {
57+ Text(connection.name)
58+ .fontWeight(.medium)
59+ Text(connection.url)
60+ .font(.caption)
61+ .foregroundStyle(.secondary)
62+ Text("User: \(connection.username)")
63+ .font(.caption2)
64+ .foregroundStyle(.tertiary)
65+ }
66+
67+ Spacer()
68+
69+ if appState.activeConnection?.id == connection.id {
70+ Text("Active")
71+ .font(.caption)
72+ .padding(.horizontal, 8)
73+ .padding(.vertical, 2)
74+ .background(.green.opacity(0.1))
75+ .foregroundStyle(.green)
76+ .clipShape(RoundedRectangle(cornerRadius: 4))
77+ }
78+
79+ Button(role: .destructive) {
80+ appState.removeConnection(connection)
81+ } label: {
82+ Image(systemName: "trash")
83+ }
84+ }
85+ .padding(.vertical, 4)
86+ }
87+ }
88+
89+ HStack {
90+ Spacer()
91+ Button("Add Connection...") {
92+ appState.editingConnection = nil
93+ appState.showConnectionSheet = true
94+ }
95+ }
96+ .padding()
97+ }
98+ .tabItem {
99+ Label("Connections", systemImage: "server.rack")
100+ }
101+ }
102+ .frame(width: 500, height: 400)
103+ }
104+}
ParseableViewer/Views/SidebarView.swiftnew file+317
@@ -0,0 +1,317 @@
1+import SwiftUI
2+
3+struct SidebarView: View {
4+ @Environment(AppState.self) private var appState
5+ @State private var showCreateStream = false
6+ @State private var newStreamName = ""
7+ @State private var showDeleteConfirm = false
8+ @State private var streamToDelete: String?
9+ @State private var deleteStreamDetail: String?
10+
11+ /// Validates a stream name, returning an error message or nil if valid.
12+ static func validateStreamName(_ name: String) -> String? {
13+ if name.trimmingCharacters(in: .whitespaces).isEmpty {
14+ return "Stream name cannot be empty."
15+ }
16+ if name.count > 255 {
17+ return "Stream name must be 255 characters or fewer."
18+ }
19+ // Allow alphanumeric, hyphens, underscores, and dots
20+ let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_."))
21+ if !name.unicodeScalars.allSatisfy({ allowed.contains($0) }) {
22+ return "Stream name can only contain letters, numbers, hyphens, underscores, and dots."
23+ }
24+ if name.hasPrefix(".") || name.hasPrefix("-") {
25+ return "Stream name cannot start with a dot or hyphen."
26+ }
27+ return nil
28+ }
29+
30+ var body: some View {
31+ @Bindable var appState = appState
32+
33+ VStack(spacing: 0) {
34+ // Connection status
35+ ConnectionStatusView()
36+
37+ Divider()
38+
39+ if appState.isConnected {
40+ // Stream list
41+ List(selection: $appState.selectedStream) {
42+ Section("Log Streams (\(appState.filteredStreams.count))") {
43+ ForEach(appState.filteredStreams) { stream in
44+ StreamRowView(stream: stream)
45+ .tag(stream.name)
46+ .contextMenu {
47+ Button("View Info") {
48+ appState.selectedStream = stream.name
49+ appState.currentTab = .streamInfo
50+ }
51+ Button("Query") {
52+ appState.selectedStream = stream.name
53+ appState.currentTab = .query
54+ }
55+ Button("Live Tail") {
56+ appState.selectedStream = stream.name
57+ appState.currentTab = .liveTail
58+ }
59+ Divider()
60+ Button("Delete...", role: .destructive) {
61+ streamToDelete = stream.name
62+ deleteStreamDetail = nil
63+ Task {
64+ if let client = appState.client,
65+ let stats = try? await client.getStreamStats(stream: stream.name) {
66+ var parts: [String] = []
67+ if let count = stats.ingestion?.lifetime_count {
68+ parts.append("\(count) total records ingested")
69+ }
70+ if let size = stats.storage?.lifetime_size ?? stats.storage?.size {
71+ parts.append("\(size) storage used")
72+ }
73+ if !parts.isEmpty {
74+ deleteStreamDetail = parts.joined(separator: ", ")
75+ }
76+ }
77+ showDeleteConfirm = true
78+ }
79+ }
80+ }
81+ }
82+ }
83+
84+ if !appState.savedQueries.isEmpty {
85+ Section("Saved Queries") {
86+ ForEach(appState.savedQueries) { query in
87+ SavedQueryRow(query: query)
88+ }
89+ }
90+ }
91+ }
92+ .listStyle(.sidebar)
93+ .searchable(text: $appState.streamSearchText, prompt: "Filter streams")
94+
95+ // Stream load error with retry
96+ if let streamError = appState.streamLoadError {
97+ HStack(spacing: 6) {
98+ Image(systemName: "exclamationmark.triangle")
99+ .foregroundStyle(.orange)
100+ Text(streamError)
101+ .font(.caption2)
102+ .foregroundStyle(.secondary)
103+ .lineLimit(2)
104+ Spacer()
105+ Button("Retry") {
106+ Task { await appState.refreshStreams() }
107+ }
108+ .controlSize(.small)
109+ .buttonStyle(.bordered)
110+ }
111+ .padding(.horizontal, 8)
112+ .padding(.vertical, 4)
113+ .background(Color.orange.opacity(0.08))
114+ }
115+
116+ // Bottom toolbar
117+ HStack {
118+ Button {
119+ showCreateStream = true
120+ } label: {
121+ Image(systemName: "plus")
122+ }
123+ .help("Create new stream")
124+ .accessibilityLabel("Create new stream")
125+
126+ Button {
127+ Task { await appState.refreshStreams() }
128+ } label: {
129+ Image(systemName: "arrow.clockwise")
130+ }
131+ .help("Refresh streams")
132+ .disabled(appState.isLoadingStreams)
133+ .accessibilityLabel("Refresh streams")
134+
135+ Spacer()
136+
137+ if appState.isLoadingStreams {
138+ ProgressView()
139+ .controlSize(.small)
140+ .accessibilityLabel("Loading streams")
141+ }
142+ }
143+ .padding(8)
144+ .background(.bar)
145+ } else {
146+ VStack {
147+ Spacer()
148+ Text("Not connected")
149+ .foregroundStyle(.secondary)
150+ Spacer()
151+ }
152+ }
153+ }
154+ .frame(minWidth: 220)
155+ .alert("Create Log Stream", isPresented: $showCreateStream) {
156+ TextField("Stream name", text: $newStreamName)
157+ Button("Create") {
158+ let name = newStreamName.trimmingCharacters(in: .whitespaces)
159+ guard !name.isEmpty else { return }
160+ if let error = Self.validateStreamName(name) {
161+ appState.showErrorMessage(error)
162+ return
163+ }
164+ Task {
165+ do {
166+ try await appState.createStream(name: name)
167+ newStreamName = ""
168+ } catch {
169+ appState.showErrorMessage(ParseableError.userFriendlyMessage(for: error))
170+ }
171+ }
172+ }
173+ Button("Cancel", role: .cancel) {
174+ newStreamName = ""
175+ }
176+ }
177+ .alert("Delete Stream", isPresented: $showDeleteConfirm) {
178+ Button("Delete", role: .destructive) {
179+ if let name = streamToDelete {
180+ Task {
181+ do {
182+ try await appState.deleteStream(name: name)
183+ } catch {
184+ appState.showErrorMessage(ParseableError.userFriendlyMessage(for: error))
185+ }
186+ }
187+ }
188+ }
189+ Button("Cancel", role: .cancel) {}
190+ } message: {
191+ if let detail = deleteStreamDetail {
192+ Text("Are you sure you want to delete \"\(streamToDelete ?? "")\"? This stream has \(detail). All data will be removed and this cannot be undone.")
193+ } else {
194+ Text("Are you sure you want to delete \"\(streamToDelete ?? "")\"? This will remove all data and cannot be undone.")
195+ }
196+ }
197+ }
198+}
199+
200+struct ConnectionStatusView: View {
201+ @Environment(AppState.self) private var appState
202+
203+ var body: some View {
204+ VStack(alignment: .leading, spacing: 4) {
205+ if let connection = appState.activeConnection {
206+ HStack {
207+ Circle()
208+ .fill(appState.isConnected ? .green : .red)
209+ .frame(width: 8, height: 8)
210+ .accessibilityLabel(appState.isConnected ? "Connected" : "Disconnected")
211+ Text(connection.name)
212+ .font(.headline)
213+ .lineLimit(1)
214+ Spacer()
215+ Menu {
216+ ForEach(appState.connections) { conn in
217+ Button(conn.name) {
218+ Task { await appState.connect(to: conn) }
219+ }
220+ }
221+ Divider()
222+ Button("Add Connection...") {
223+ appState.editingConnection = nil
224+ appState.showConnectionSheet = true
225+ }
226+ Button("Edit Connection...") {
227+ appState.editingConnection = connection
228+ appState.showConnectionSheet = true
229+ }
230+ Divider()
231+ Button("Disconnect") {
232+ appState.disconnect()
233+ }
234+ } label: {
235+ Image(systemName: "ellipsis.circle")
236+ }
237+ .menuStyle(.borderlessButton)
238+ .frame(width: 24)
239+ .accessibilityLabel("Connection options")
240+ }
241+ Text(connection.url)
242+ .font(.caption)
243+ .foregroundStyle(.secondary)
244+ .lineLimit(1)
245+ } else if appState.isConnecting {
246+ HStack {
247+ ProgressView()
248+ .controlSize(.small)
249+ Text("Connecting...")
250+ .foregroundStyle(.secondary)
251+ }
252+ } else {
253+ Button("Connect to Server...") {
254+ appState.editingConnection = nil
255+ appState.showConnectionSheet = true
256+ }
257+ .buttonStyle(.bordered)
258+ }
259+ }
260+ .padding(10)
261+ }
262+}
263+
264+struct StreamRowView: View {
265+ let stream: LogStream
266+
267+ var body: some View {
268+ HStack {
269+ Image(systemName: "doc.text")
270+ .foregroundStyle(.secondary)
271+ Text(stream.name)
272+ .lineLimit(1)
273+ }
274+ }
275+}
276+
277+struct SavedQueryRow: View {
278+ @Environment(AppState.self) private var appState
279+ let query: SavedQuery
280+
281+ private var streamExists: Bool {
282+ appState.streams.contains { $0.name == query.stream }
283+ }
284+
285+ var body: some View {
286+ Button {
287+ appState.selectedStream = query.stream
288+ appState.pendingSavedQuerySQL = query.sql
289+ appState.currentTab = .query
290+ } label: {
291+ HStack {
292+ Image(systemName: "bookmark")
293+ .foregroundStyle(streamExists ? .orange : .secondary)
294+ VStack(alignment: .leading) {
295+ Text(query.name)
296+ .lineLimit(1)
297+ HStack(spacing: 4) {
298+ Text(query.stream)
299+ if !streamExists {
300+ Image(systemName: "exclamationmark.triangle.fill")
301+ .foregroundStyle(.orange)
302+ .help("Stream \"\(query.stream)\" no longer exists")
303+ }
304+ }
305+ .font(.caption2)
306+ .foregroundStyle(.secondary)
307+ }
308+ }
309+ }
310+ .buttonStyle(.plain)
311+ .contextMenu {
312+ Button("Delete", role: .destructive) {
313+ appState.removeSavedQuery(query)
314+ }
315+ }
316+ }
317+}
ParseableViewer/Views/StreamDetailView.swiftnew file+254
@@ -0,0 +1,254 @@
1+import SwiftUI
2+
3+struct StreamDetailView: View {
4+ @Environment(AppState.self) private var appState
5+ @State private var schema: StreamSchema?
6+ @State private var stats: StreamStats?
7+ @State private var info: StreamInfo?
8+ @State private var retention: [RetentionConfig] = []
9+ @State private var isLoading = false
10+ @State private var errorMessage: String?
11+
12+ var body: some View {
13+ Group {
14+ if let stream = appState.selectedStream {
15+ ScrollView {
16+ VStack(alignment: .leading, spacing: 16) {
17+ // Header
18+ HStack {
19+ VStack(alignment: .leading) {
20+ Text(stream)
21+ .font(.title2)
22+ .fontWeight(.semibold)
23+
24+ if let info {
25+ if let created = info.createdAt {
26+ Text("Created: \(created)")
27+ .font(.caption)
28+ .foregroundStyle(.secondary)
29+ }
30+ }
31+ }
32+
33+ Spacer()
34+
35+ Button {
36+ Task { await loadData(stream: stream) }
37+ } label: {
38+ Image(systemName: "arrow.clockwise")
39+ }
40+ .disabled(isLoading)
41+ .accessibilityLabel("Refresh stream details")
42+ }
43+
44+ if isLoading {
45+ ProgressView("Loading stream information...")
46+ .frame(maxWidth: .infinity)
47+ }
48+
49+ if let error = errorMessage {
50+ HStack {
51+ Image(systemName: "exclamationmark.triangle")
52+ Text(error)
53+ Spacer()
54+ Button("Retry") {
55+ Task { await loadData(stream: stream) }
56+ }
57+ .buttonStyle(.bordered)
58+ .controlSize(.small)
59+ }
60+ .foregroundStyle(.red)
61+ .font(.caption)
62+ }
63+
64+ // Stats section
65+ if let stats {
66+ GroupBox("Statistics") {
67+ LazyVGrid(columns: [
68+ GridItem(.flexible()),
69+ GridItem(.flexible()),
70+ GridItem(.flexible())
71+ ], spacing: 12) {
72+ if let ingestion = stats.ingestion {
73+ StatCard(title: "Event Count", value: ingestion.count.map(String.init) ?? "N/A")
74+ StatCard(title: "Ingestion Size", value: ingestion.size ?? "N/A")
75+ StatCard(title: "Format", value: ingestion.format ?? "N/A")
76+ if let lc = ingestion.lifetime_count {
77+ StatCard(title: "Lifetime Count", value: String(lc))
78+ }
79+ if let ls = ingestion.lifetime_size {
80+ StatCard(title: "Lifetime Size", value: ls)
81+ }
82+ }
83+ if let storage = stats.storage {
84+ StatCard(title: "Storage Size", value: storage.size ?? "N/A")
85+ StatCard(title: "Storage Type", value: storage.type ?? "N/A")
86+ if let ls = storage.lifetime_size {
87+ StatCard(title: "Lifetime Storage", value: ls)
88+ }
89+ }
90+ }
91+ .padding(8)
92+ }
93+ }
94+
95+ // Info section
96+ if let info {
97+ GroupBox("Stream Configuration") {
98+ VStack(alignment: .leading, spacing: 8) {
99+ InfoRow(label: "Created At", value: info.createdAt ?? "N/A")
100+ InfoRow(label: "First Event At", value: info.firstEventAt ?? "N/A")
101+ InfoRow(label: "Cache Enabled", value: info.cacheEnabled.map { $0 ? "Yes" : "No" } ?? "N/A")
102+ InfoRow(label: "Time Partition", value: info.timePartition ?? "N/A")
103+ InfoRow(label: "Static Schema", value: info.staticSchemaFlag.map { $0 ? "Yes" : "No" } ?? "N/A")
104+ }
105+ .padding(8)
106+ }
107+ }
108+
109+ // Retention section
110+ if !retention.isEmpty {
111+ GroupBox("Retention Policy") {
112+ VStack(alignment: .leading, spacing: 8) {
113+ ForEach(Array(retention.enumerated()), id: \.offset) { _, config in
114+ VStack(alignment: .leading, spacing: 4) {
115+ if let desc = config.description {
116+ Text(desc)
117+ }
118+ if let duration = config.duration {
119+ InfoRow(label: "Duration", value: duration)
120+ }
121+ if let action = config.action {
122+ InfoRow(label: "Action", value: action)
123+ }
124+ }
125+ }
126+ }
127+ .padding(8)
128+ }
129+ }
130+
131+ // Schema section
132+ if let schema {
133+ GroupBox("Schema (\(schema.fields.count) fields)") {
134+ VStack(spacing: 0) {
135+ // Header
136+ HStack {
137+ Text("Field Name")
138+ .fontWeight(.semibold)
139+ .frame(maxWidth: .infinity, alignment: .leading)
140+ Text("Data Type")
141+ .fontWeight(.semibold)
142+ .frame(width: 200, alignment: .leading)
143+ }
144+ .font(.caption)
145+ .padding(.horizontal, 8)
146+ .padding(.vertical, 4)
147+ .background(Color.primary.opacity(0.05))
148+
149+ ForEach(schema.fields) { field in
150+ HStack {
151+ Text(field.name)
152+ .font(.system(.caption, design: .monospaced))
153+ .frame(maxWidth: .infinity, alignment: .leading)
154+ Text(field.dataType)
155+ .font(.system(.caption, design: .monospaced))
156+ .foregroundStyle(.secondary)
157+ .frame(width: 200, alignment: .leading)
158+ }
159+ .padding(.horizontal, 8)
160+ .padding(.vertical, 3)
161+ }
162+ }
163+ }
164+ }
165+ }
166+ .padding()
167+ }
168+ .task(id: stream) {
169+ await loadData(stream: stream)
170+ }
171+ } else {
172+ VStack {
173+ Text("Select a stream to view its details")
174+ .foregroundStyle(.secondary)
175+ }
176+ .frame(maxWidth: .infinity, maxHeight: .infinity)
177+ }
178+ }
179+ }
180+
181+ private func loadData(stream: String) async {
182+ guard let client = appState.client else { return }
183+ isLoading = true
184+ errorMessage = nil
185+ // Clear previous stream's data so it isn't shown under the new header
186+ schema = nil
187+ stats = nil
188+ info = nil
189+ retention = []
190+
191+ // Fetch all stream data concurrently
192+ async let schemaFetch = client.getStreamSchema(stream: stream)
193+ async let statsFetch = client.getStreamStats(stream: stream)
194+ async let infoFetch = client.getStreamInfo(stream: stream)
195+ async let retentionFetch = client.getRetention(stream: stream)
196+
197+ var failures: [String] = []
198+ var sawNotFound = false
199+
200+ func checkNotFound(_ error: Error) {
201+ if case .serverError(let code, _) = error as? ParseableError, code == 404 { sawNotFound = true }
202+ }
203+
204+ do { schema = try await schemaFetch } catch { schema = nil; failures.append("schema"); checkNotFound(error) }
205+ do { stats = try await statsFetch } catch { stats = nil; failures.append("stats"); checkNotFound(error) }
206+ do { info = try await infoFetch } catch { info = nil; failures.append("info"); checkNotFound(error) }
207+ do { retention = try await retentionFetch } catch { retention = []; failures.append("retention"); checkNotFound(error) }
208+
209+ if sawNotFound && failures.count == 4 {
210+ errorMessage = "Stream \"\(stream)\" was not found. It may have been deleted."
211+ } else if !failures.isEmpty {
212+ errorMessage = "Failed to load: \(failures.joined(separator: ", "))"
213+ }
214+
215+ isLoading = false
216+ }
217+}
218+
219+struct StatCard: View {
220+ let title: String
221+ let value: String
222+
223+ var body: some View {
224+ VStack(spacing: 4) {
225+ Text(title)
226+ .font(.caption)
227+ .foregroundStyle(.secondary)
228+ Text(value)
229+ .font(.headline)
230+ .fontWeight(.medium)
231+ }
232+ .frame(maxWidth: .infinity)
233+ .padding(8)
234+ .background(Color.primary.opacity(0.03))
235+ .clipShape(RoundedRectangle(cornerRadius: 8))
236+ }
237+}
238+
239+struct InfoRow: View {
240+ let label: String
241+ let value: String
242+
243+ var body: some View {
244+ HStack {
245+ Text(label)
246+ .foregroundStyle(.secondary)
247+ .frame(width: 140, alignment: .trailing)
248+ Text(value)
249+ .textSelection(.enabled)
250+ Spacer()
251+ }
252+ .font(.caption)
253+ }
254+}
ParseableViewer/Views/TimeRangePicker.swiftnew file+90
@@ -0,0 +1,90 @@
1+import SwiftUI
2+
3+struct TimeRangePicker: View {
4+ @Binding var option: QueryViewModel.TimeRangeOption
5+ @Binding var customStart: Date
6+ @Binding var customEnd: Date
7+ @State private var showCustomPicker = false
8+
9+ var body: some View {
10+ HStack(spacing: 8) {
11+ Image(systemName: "clock")
12+ .foregroundStyle(.secondary)
13+
14+ Picker("Time Range", selection: $option) {
15+ ForEach(QueryViewModel.TimeRangeOption.allCases) { opt in
16+ Text(opt.rawValue).tag(opt)
17+ }
18+ }
19+ .frame(width: 180)
20+ .onChange(of: option) { _, newValue in
21+ if newValue == .custom {
22+ showCustomPicker = true
23+ }
24+ }
25+
26+ if option == .custom {
27+ Button("Set Range...") {
28+ showCustomPicker = true
29+ }
30+ .font(.caption)
31+ }
32+ }
33+ .popover(isPresented: $showCustomPicker) {
34+ VStack(spacing: 12) {
35+ Text("Custom Time Range")
36+ .font(.headline)
37+
38+ DatePicker("Start:", selection: $customStart)
39+ .datePickerStyle(.field)
40+ DatePicker("End:", selection: $customEnd)
41+ .datePickerStyle(.field)
42+
43+ if customEnd < customStart {
44+ HStack(spacing: 4) {
45+ Image(systemName: "exclamationmark.triangle.fill")
46+ Text("End date must be after start date")
47+ }
48+ .font(.caption)
49+ .foregroundStyle(.red)
50+ }
51+
52+ HStack {
53+ // Quick presets
54+ Menu("Presets") {
55+ Button("Last hour") {
56+ let now = Date()
57+ customStart = Calendar.current.date(byAdding: .hour, value: -1, to: now) ?? now.addingTimeInterval(-3600)
58+ customEnd = now
59+ }
60+ Button("Today") {
61+ customStart = Calendar.current.startOfDay(for: Date())
62+ customEnd = Date()
63+ }
64+ Button("Yesterday") {
65+ let now = Date()
66+ let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now) ?? now.addingTimeInterval(-86400)
67+ customStart = Calendar.current.startOfDay(for: yesterday)
68+ customEnd = Calendar.current.startOfDay(for: now)
69+ }
70+ Button("Last 7 days") {
71+ let now = Date()
72+ customStart = Calendar.current.date(byAdding: .day, value: -7, to: now) ?? now.addingTimeInterval(-604800)
73+ customEnd = now
74+ }
75+ }
76+
77+ Spacer()
78+
79+ Button("Done") {
80+ showCustomPicker = false
81+ }
82+ .buttonStyle(.borderedProminent)
83+ .disabled(customEnd < customStart)
84+ }
85+ }
86+ .padding()
87+ .frame(width: 300)
88+ }
89+ }
90+}
ParseableViewer/Views/UsersView.swiftnew file+93
@@ -0,0 +1,93 @@
1+import SwiftUI
2+
3+struct UsersView: View {
4+ @Environment(AppState.self) private var appState
5+ @State private var users: [UserInfo] = []
6+ @State private var isLoading = false
7+ @State private var errorMessage: String?
8+
9+ var body: some View {
10+ VStack(spacing: 0) {
11+ HStack {
12+ Text("Users")
13+ .font(.headline)
14+ Spacer()
15+ Button {
16+ Task { await loadUsers() }
17+ } label: {
18+ Image(systemName: "arrow.clockwise")
19+ }
20+ .disabled(isLoading)
21+ .accessibilityLabel("Refresh users")
22+ }
23+ .padding()
24+
25+ Divider()
26+
27+ if isLoading {
28+ ProgressView()
29+ .frame(maxWidth: .infinity, maxHeight: .infinity)
30+ } else if let error = errorMessage {
31+ VStack(spacing: 8) {
32+ Image(systemName: "exclamationmark.triangle")
33+ .font(.title)
34+ .foregroundStyle(.secondary)
35+ Text(error)
36+ .foregroundStyle(.secondary)
37+ Button("Retry") {
38+ Task { await loadUsers() }
39+ }
40+ .buttonStyle(.bordered)
41+ }
42+ .frame(maxWidth: .infinity, maxHeight: .infinity)
43+ } else if users.isEmpty {
44+ VStack(spacing: 8) {
45+ Image(systemName: "person.2")
46+ .font(.system(size: 32))
47+ .foregroundStyle(.secondary)
48+ Text("No users found")
49+ .foregroundStyle(.secondary)
50+ }
51+ .frame(maxWidth: .infinity, maxHeight: .infinity)
52+ } else {
53+ List {
54+ ForEach(users) { user in
55+ HStack {
56+ Image(systemName: "person.circle")
57+ .font(.title2)
58+ .foregroundStyle(.secondary)
59+ VStack(alignment: .leading) {
60+ Text(user.id)
61+ .fontWeight(.medium)
62+ if let method = user.method {
63+ Text("Auth: \(method)")
64+ .font(.caption)
65+ .foregroundStyle(.secondary)
66+ }
67+ }
68+ Spacer()
69+ }
70+ .padding(.vertical, 4)
71+ }
72+ }
73+ }
74+ }
75+ .task {
76+ await loadUsers()
77+ }
78+ }
79+
80+ private func loadUsers() async {
81+ guard let client = appState.client else { return }
82+ isLoading = true
83+ errorMessage = nil
84+
85+ do {
86+ users = try await client.listUsers()
87+ } catch {
88+ errorMessage = ParseableError.userFriendlyMessage(for: error)
89+ }
90+
91+ isLoading = false
92+ }
93+}
ParseableViewerTests/ConnectionStoreTests.swiftnew file+132
@@ -0,0 +1,132 @@
1+import XCTest
2+@testable import ParseableViewer
3+
4+final class ConnectionStoreTests: XCTestCase {
5+ private let testSuiteKey = "parseable_connections_test"
6+
7+ override func tearDown() {
8+ super.tearDown()
9+ // Clean up any test data from UserDefaults
10+ UserDefaults.standard.removeObject(forKey: "parseable_connections")
11+ UserDefaults.standard.removeObject(forKey: "parseable_active_connection_id")
12+ UserDefaults.standard.removeObject(forKey: "parseable_saved_queries")
13+ }
14+
15+ // MARK: - Active connection ID
16+
17+ func testSaveAndLoadActiveConnectionID() {
18+ let id = UUID()
19+ ConnectionStore.saveActiveConnectionID(id)
20+ let loaded = ConnectionStore.loadActiveConnectionID()
21+ XCTAssertEqual(loaded, id)
22+ }
23+
24+ func testSaveNilActiveConnectionID() {
25+ let id = UUID()
26+ ConnectionStore.saveActiveConnectionID(id)
27+ ConnectionStore.saveActiveConnectionID(nil)
28+ let loaded = ConnectionStore.loadActiveConnectionID()
29+ XCTAssertNil(loaded)
30+ }
31+
32+ func testLoadActiveConnectionIDWhenEmpty() {
33+ UserDefaults.standard.removeObject(forKey: "parseable_active_connection_id")
34+ let loaded = ConnectionStore.loadActiveConnectionID()
35+ XCTAssertNil(loaded)
36+ }
37+
38+ // MARK: - Connection persistence
39+
40+ func testLoadConnectionsWhenEmpty() {
41+ UserDefaults.standard.removeObject(forKey: "parseable_connections")
42+ let connections = ConnectionStore.loadConnections()
43+ XCTAssertTrue(connections.isEmpty)
44+ }
45+
46+ func testSaveAndLoadConnections() {
47+ let connection = ServerConnection(
48+ name: "Test Server",
49+ url: "https://logs.example.com",
50+ username: "admin",
51+ password: "secret123"
52+ )
53+ ConnectionStore.saveConnections([connection])
54+ let loaded = ConnectionStore.loadConnections()
55+ XCTAssertEqual(loaded.count, 1)
56+ XCTAssertEqual(loaded[0].name, "Test Server")
57+ XCTAssertEqual(loaded[0].url, "https://logs.example.com")
58+ XCTAssertEqual(loaded[0].username, "admin")
59+ }
60+
61+ func testSaveMultipleConnections() {
62+ let c1 = ServerConnection(name: "Prod", url: "https://prod.example.com", username: "admin", password: "p1")
63+ let c2 = ServerConnection(name: "Dev", url: "https://dev.example.com", username: "dev", password: "p2")
64+ ConnectionStore.saveConnections([c1, c2])
65+ let loaded = ConnectionStore.loadConnections()
66+ XCTAssertEqual(loaded.count, 2)
67+ }
68+
69+ func testPasswordNotInUserDefaults() {
70+ let connection = ServerConnection(
71+ name: "Test",
72+ url: "https://example.com",
73+ username: "admin",
74+ password: "supersecret"
75+ )
76+ ConnectionStore.saveConnections([connection])
77+
78+ // Verify password is NOT in the UserDefaults data
79+ if let data = UserDefaults.standard.data(forKey: "parseable_connections"),
80+ let jsonString = String(data: data, encoding: .utf8) {
81+ XCTAssertFalse(jsonString.contains("supersecret"),
82+ "Password should not be stored in UserDefaults JSON")
83+ }
84+ }
85+
86+ // MARK: - SavedQueryStore
87+
88+ func testSavedQueryStoreLoadEmpty() {
89+ UserDefaults.standard.removeObject(forKey: "parseable_saved_queries")
90+ let queries = SavedQueryStore.load()
91+ XCTAssertTrue(queries.isEmpty)
92+ }
93+
94+ func testSavedQueryStoreRoundTrip() {
95+ let query = SavedQuery(
96+ name: "Recent Errors",
97+ sql: "SELECT * FROM logs WHERE level = 'error' LIMIT 100",
98+ stream: "backend-logs"
99+ )
100+ SavedQueryStore.save([query])
101+ let loaded = SavedQueryStore.load()
102+ XCTAssertEqual(loaded.count, 1)
103+ XCTAssertEqual(loaded[0].name, "Recent Errors")
104+ XCTAssertEqual(loaded[0].sql, "SELECT * FROM logs WHERE level = 'error' LIMIT 100")
105+ XCTAssertEqual(loaded[0].stream, "backend-logs")
106+ }
107+
108+ func testSavedQueryStoreMultiple() {
109+ let q1 = SavedQuery(name: "Query 1", sql: "SELECT 1", stream: "s1")
110+ let q2 = SavedQuery(name: "Query 2", sql: "SELECT 2", stream: "s2")
111+ SavedQueryStore.save([q1, q2])
112+ let loaded = SavedQueryStore.load()
113+ XCTAssertEqual(loaded.count, 2)
114+ }
115+
116+ // MARK: - SavedQuery model
117+
118+ func testSavedQueryIdentifiable() {
119+ let q1 = SavedQuery(name: "A", sql: "SELECT 1", stream: "s")
120+ let q2 = SavedQuery(name: "B", sql: "SELECT 2", stream: "s")
121+ XCTAssertNotEqual(q1.id, q2.id)
122+ }
123+
124+ func testSavedQueryHashable() {
125+ let q1 = SavedQuery(name: "A", sql: "SELECT 1", stream: "s")
126+ var set = Set<SavedQuery>()
127+ set.insert(q1)
128+ XCTAssertEqual(set.count, 1)
129+ set.insert(q1) // Same query again
130+ XCTAssertEqual(set.count, 1)
131+ }
132+}
ParseableViewerTests/JSONValueTests.swiftnew file+270
@@ -0,0 +1,270 @@
1+import XCTest
2+@testable import ParseableViewer
3+
4+final class JSONValueTests: XCTestCase {
5+ // MARK: - Decoding
6+
7+ func testDecodeNull() throws {
8+ let data = "null".data(using: .utf8)!
9+ let value = try JSONDecoder().decode(JSONValue.self, from: data)
10+ XCTAssertEqual(value, .null)
11+ }
12+
13+ func testDecodeBool() throws {
14+ let trueData = "true".data(using: .utf8)!
15+ let falseData = "false".data(using: .utf8)!
16+ XCTAssertEqual(try JSONDecoder().decode(JSONValue.self, from: trueData), .bool(true))
17+ XCTAssertEqual(try JSONDecoder().decode(JSONValue.self, from: falseData), .bool(false))
18+ }
19+
20+ func testDecodeInt() throws {
21+ let data = "42".data(using: .utf8)!
22+ let value = try JSONDecoder().decode(JSONValue.self, from: data)
23+ XCTAssertEqual(value, .int(42))
24+ }
25+
26+ func testDecodeDouble() throws {
27+ let data = "3.14".data(using: .utf8)!
28+ let value = try JSONDecoder().decode(JSONValue.self, from: data)
29+ XCTAssertEqual(value, .double(3.14))
30+ }
31+
32+ func testDecodeString() throws {
33+ let data = "\"hello\"".data(using: .utf8)!
34+ let value = try JSONDecoder().decode(JSONValue.self, from: data)
35+ XCTAssertEqual(value, .string("hello"))
36+ }
37+
38+ func testDecodeArray() throws {
39+ let data = "[1, \"two\", true]".data(using: .utf8)!
40+ let value = try JSONDecoder().decode(JSONValue.self, from: data)
41+ XCTAssertEqual(value, .array([.int(1), .string("two"), .bool(true)]))
42+ }
43+
44+ func testDecodeObject() throws {
45+ let data = "{\"key\": \"value\", \"count\": 5}".data(using: .utf8)!
46+ let value = try JSONDecoder().decode(JSONValue.self, from: data)
47+ XCTAssertEqual(value, .object(["key": .string("value"), "count": .int(5)]))
48+ }
49+
50+ func testDecodeNestedObject() throws {
51+ let json = """
52+ {"user": {"name": "Alice", "tags": [1, 2]}, "active": true}
53+ """
54+ let data = json.data(using: .utf8)!
55+ let value = try JSONDecoder().decode(JSONValue.self, from: data)
56+ let expected: JSONValue = .object([
57+ "user": .object(["name": .string("Alice"), "tags": .array([.int(1), .int(2)])]),
58+ "active": .bool(true)
59+ ])
60+ XCTAssertEqual(value, expected)
61+ }
62+
63+ // MARK: - Encoding roundtrip
64+
65+ func testRoundtrip() throws {
66+ let original: JSONValue = .object([
67+ "name": .string("test"),
68+ "count": .int(42),
69+ "active": .bool(true),
70+ "data": .array([.null, .double(1.5)]),
71+ "nested": .object(["x": .string("y")])
72+ ])
73+ let data = try JSONEncoder().encode(original)
74+ let decoded = try JSONDecoder().decode(JSONValue.self, from: data)
75+ XCTAssertEqual(original, decoded)
76+ }
77+
78+ // MARK: - displayString
79+
80+ func testDisplayStringNull() {
81+ XCTAssertEqual(JSONValue.null.displayString, "null")
82+ }
83+
84+ func testDisplayStringBool() {
85+ XCTAssertEqual(JSONValue.bool(true).displayString, "true")
86+ XCTAssertEqual(JSONValue.bool(false).displayString, "false")
87+ }
88+
89+ func testDisplayStringInt() {
90+ XCTAssertEqual(JSONValue.int(42).displayString, "42")
91+ XCTAssertEqual(JSONValue.int(-1).displayString, "-1")
92+ }
93+
94+ func testDisplayStringDouble() {
95+ // Whole doubles show without decimal
96+ XCTAssertEqual(JSONValue.double(5.0).displayString, "5")
97+ // Fractional doubles keep their precision
98+ XCTAssertTrue(JSONValue.double(3.14).displayString.hasPrefix("3.14"))
99+ }
100+
101+ func testDisplayStringString() {
102+ XCTAssertEqual(JSONValue.string("hello").displayString, "hello")
103+ XCTAssertEqual(JSONValue.string("").displayString, "")
104+ }
105+
106+ func testDisplayStringArray() {
107+ let arr = JSONValue.array([.int(1), .int(2), .int(3)])
108+ XCTAssertEqual(arr.displayString, "[3 items]")
109+ }
110+
111+ func testDisplayStringObject() {
112+ let obj = JSONValue.object(["a": .int(1), "b": .int(2)])
113+ XCTAssertEqual(obj.displayString, "{2 fields}")
114+ }
115+
116+ // MARK: - isScalar
117+
118+ func testIsScalar() {
119+ XCTAssertTrue(JSONValue.null.isScalar)
120+ XCTAssertTrue(JSONValue.bool(true).isScalar)
121+ XCTAssertTrue(JSONValue.int(0).isScalar)
122+ XCTAssertTrue(JSONValue.double(0.0).isScalar)
123+ XCTAssertTrue(JSONValue.string("").isScalar)
124+ XCTAssertFalse(JSONValue.array([]).isScalar)
125+ XCTAssertFalse(JSONValue.object([:]).isScalar)
126+ }
127+
128+ // MARK: - Accessors
129+
130+ func testStringValue() {
131+ XCTAssertEqual(JSONValue.string("hello").stringValue, "hello")
132+ XCTAssertNil(JSONValue.int(42).stringValue)
133+ }
134+
135+ func testObjectValue() {
136+ let dict: [String: JSONValue] = ["key": .string("val")]
137+ XCTAssertEqual(JSONValue.object(dict).objectValue, dict)
138+ XCTAssertNil(JSONValue.string("x").objectValue)
139+ }
140+
141+ func testArrayValue() {
142+ let arr: [JSONValue] = [.int(1), .int(2)]
143+ XCTAssertEqual(JSONValue.array(arr).arrayValue, arr)
144+ XCTAssertNil(JSONValue.int(0).arrayValue)
145+ }
146+
147+ // MARK: - prettyPrinted
148+
149+ func testPrettyPrintedScalars() {
150+ XCTAssertEqual(JSONValue.null.prettyPrinted(), "null")
151+ XCTAssertEqual(JSONValue.bool(true).prettyPrinted(), "true")
152+ XCTAssertEqual(JSONValue.int(42).prettyPrinted(), "42")
153+ XCTAssertEqual(JSONValue.string("hi").prettyPrinted(), "\"hi\"")
154+ }
155+
156+ func testPrettyPrintedEmptyContainers() {
157+ XCTAssertEqual(JSONValue.array([]).prettyPrinted(), "[]")
158+ XCTAssertEqual(JSONValue.object([:]).prettyPrinted(), "{}")
159+ }
160+
161+ func testPrettyPrintedSmallArray() {
162+ let arr = JSONValue.array([.int(1), .int(2), .int(3)])
163+ XCTAssertEqual(arr.prettyPrinted(), "[1, 2, 3]")
164+ }
165+
166+ func testPrettyPrintedMaxDepthTruncates() {
167+ // Build a nested object 5 levels deep
168+ var value: JSONValue = .string("leaf")
169+ for i in (0..<5).reversed() {
170+ value = .object(["level\(i)": value])
171+ }
172+ // With maxDepth=3, should hit displayString fallback
173+ let result = value.prettyPrinted(maxDepth: 3)
174+ // The outer 3 levels render normally; deeper levels use displayString
175+ XCTAssertTrue(result.contains("level0"))
176+ XCTAssertTrue(result.contains("level1"))
177+ XCTAssertTrue(result.contains("level2"))
178+ // level3 should be rendered as displayString summary, not expanded
179+ XCTAssertFalse(result.contains("\"level3\":"))
180+ }
181+
182+ func testPrettyPrintedDefaultMaxDepthIsDeep() {
183+ // 10 levels deep should render fine with default maxDepth=50
184+ var value: JSONValue = .string("deep")
185+ for _ in 0..<10 {
186+ value = .object(["nest": value])
187+ }
188+ let result = value.prettyPrinted()
189+ XCTAssertTrue(result.contains("\"deep\""))
190+ }
191+
192+ // MARK: - JSON string escaping
193+
194+ func testEscapeJSONStringQuotes() {
195+ let result = JSONValue.escapeJSONString("hello \"world\"")
196+ XCTAssertEqual(result, "hello \\\"world\\\"")
197+ }
198+
199+ func testEscapeJSONStringBackslash() {
200+ let result = JSONValue.escapeJSONString("path\\to\\file")
201+ XCTAssertEqual(result, "path\\\\to\\\\file")
202+ }
203+
204+ func testEscapeJSONStringNewlineAndTab() {
205+ let result = JSONValue.escapeJSONString("line1\nline2\ttab")
206+ XCTAssertEqual(result, "line1\\nline2\\ttab")
207+ }
208+
209+ func testEscapeJSONStringCarriageReturn() {
210+ let result = JSONValue.escapeJSONString("before\rafter")
211+ XCTAssertEqual(result, "before\\rafter")
212+ }
213+
214+ func testEscapeJSONStringNoEscapingNeeded() {
215+ let result = JSONValue.escapeJSONString("simple text 123")
216+ XCTAssertEqual(result, "simple text 123")
217+ }
218+
219+ func testPrettyPrintedStringWithSpecialChars() {
220+ let value = JSONValue.string("he said \"hi\" and\\or\nnewline")
221+ let result = value.prettyPrinted()
222+ XCTAssertEqual(result, "\"he said \\\"hi\\\" and\\\\or\\nnewline\"")
223+ }
224+
225+ func testPrettyPrintedObjectKeyWithSpecialChars() {
226+ let value = JSONValue.object(["key\"with\"quotes": .int(1)])
227+ let result = value.prettyPrinted()
228+ XCTAssertTrue(result.contains("key\\\"with\\\"quotes"))
229+ }
230+
231+ // MARK: - QueryResponse
232+
233+ func testQueryResponseDecode() throws {
234+ let json = """
235+ {"records": [{"level": "info", "msg": "test"}], "fields": ["level", "msg"]}
236+ """
237+ let data = json.data(using: .utf8)!
238+ let response = try JSONDecoder().decode(QueryResponse.self, from: data)
239+ XCTAssertEqual(response.records.count, 1)
240+ XCTAssertEqual(response.fields, ["level", "msg"])
241+ XCTAssertEqual(response.records[0]["level"], .string("info"))
242+ }
243+
244+ func testQueryResponseDecodeDirectArray() throws {
245+ let json = """
246+ [{"level": "error", "msg": "boom"}]
247+ """
248+ let data = json.data(using: .utf8)!
249+ // Direct array should fail for QueryResponse but succeed for [LogRecord]
250+ let result = try? JSONDecoder().decode(QueryResponse.self, from: data)
251+ XCTAssertNil(result)
252+
253+ let records = try JSONDecoder().decode([LogRecord].self, from: data)
254+ XCTAssertEqual(records.count, 1)
255+ XCTAssertEqual(records[0]["level"], .string("error"))
256+ }
257+
258+ // MARK: - LogRecord as typealias
259+
260+ func testLogRecordDecoding() throws {
261+ let json = """
262+ {"p_timestamp": "2024-01-01T00:00:00Z", "level": "info", "message": "hello world"}
263+ """
264+ let data = json.data(using: .utf8)!
265+ let record = try JSONDecoder().decode(LogRecord.self, from: data)
266+ XCTAssertEqual(record["p_timestamp"], .string("2024-01-01T00:00:00Z"))
267+ XCTAssertEqual(record["level"], .string("info"))
268+ XCTAssertEqual(record["message"], .string("hello world"))
269+ }
270+}
ParseableViewerTests/LiveTailViewModelTests.swiftnew file+281
@@ -0,0 +1,281 @@
1+import XCTest
2+@testable import ParseableViewer
3+
4+@MainActor
5+final class LiveTailViewModelTests: XCTestCase {
6+ // MARK: - Fingerprinting
7+
8+ func testFingerprintDeterministic() {
9+ let record: LogRecord = [
10+ "message": .string("hello"),
11+ "level": .string("info")
12+ ]
13+ let fp1 = LiveTailViewModel.fingerprint(for: record)
14+ let fp2 = LiveTailViewModel.fingerprint(for: record)
15+ XCTAssertEqual(fp1, fp2)
16+ }
17+
18+ func testFingerprintDifferentForDifferentRecords() {
19+ let record1: LogRecord = ["message": .string("hello")]
20+ let record2: LogRecord = ["message": .string("world")]
21+ let fp1 = LiveTailViewModel.fingerprint(for: record1)
22+ let fp2 = LiveTailViewModel.fingerprint(for: record2)
23+ XCTAssertNotEqual(fp1, fp2)
24+ }
25+
26+ func testFingerprintOrderIndependent() {
27+ // Records with same keys/values in different insertion order should produce same fingerprint
28+ // since fingerprint sorts keys
29+ let record1: LogRecord = ["a": .string("1"), "b": .string("2")]
30+ let record2: LogRecord = ["b": .string("2"), "a": .string("1")]
31+ let fp1 = LiveTailViewModel.fingerprint(for: record1)
32+ let fp2 = LiveTailViewModel.fingerprint(for: record2)
33+ XCTAssertEqual(fp1, fp2)
34+ }
35+
36+ func testFingerprintEmptyRecord() {
37+ let record: LogRecord = [:]
38+ let fp = LiveTailViewModel.fingerprint(for: record)
39+ XCTAssertFalse(fp.isEmpty)
40+ }
41+
42+ func testFingerprintDistinguishesKeyFromValue() {
43+ // Ensure "a"="b" and "ab"="" produce different fingerprints
44+ let record1: LogRecord = ["a": .string("b")]
45+ let record2: LogRecord = ["ab": .string("")]
46+ let fp1 = LiveTailViewModel.fingerprint(for: record1)
47+ let fp2 = LiveTailViewModel.fingerprint(for: record2)
48+ XCTAssertNotEqual(fp1, fp2)
49+ }
50+
51+ func testFingerprintHandlesNullValues() {
52+ let record: LogRecord = ["key": .null]
53+ let fp = LiveTailViewModel.fingerprint(for: record)
54+ XCTAssertFalse(fp.isEmpty)
55+ }
56+
57+ func testFingerprintHandlesNestedValues() {
58+ let record: LogRecord = ["data": .object(["nested": .string("value")])]
59+ let fp = LiveTailViewModel.fingerprint(for: record)
60+ XCTAssertFalse(fp.isEmpty)
61+ }
62+
63+ // MARK: - Timestamp parsing
64+
65+ func testParseTimestampISO8601WithFractionalSeconds() {
66+ let vm = LiveTailViewModel()
67+ let record: LogRecord = ["p_timestamp": .string("2024-06-15T10:30:45.123Z")]
68+ let date = vm.parseTimestamp(from: record)
69+ XCTAssertNotNil(date)
70+ }
71+
72+ func testParseTimestampISO8601Basic() {
73+ let vm = LiveTailViewModel()
74+ let record: LogRecord = ["p_timestamp": .string("2024-06-15T10:30:45Z")]
75+ let date = vm.parseTimestamp(from: record)
76+ XCTAssertNotNil(date)
77+ }
78+
79+ func testParseTimestampFallbackFields() {
80+ let vm = LiveTailViewModel()
81+
82+ // "timestamp" field
83+ let r1: LogRecord = ["timestamp": .string("2024-06-15T10:30:45Z")]
84+ XCTAssertNotNil(vm.parseTimestamp(from: r1))
85+
86+ // "time" field
87+ let r2: LogRecord = ["time": .string("2024-06-15T10:30:45Z")]
88+ XCTAssertNotNil(vm.parseTimestamp(from: r2))
89+
90+ // "@timestamp" field
91+ let r3: LogRecord = ["@timestamp": .string("2024-06-15T10:30:45Z")]
92+ XCTAssertNotNil(vm.parseTimestamp(from: r3))
93+ }
94+
95+ func testParseTimestampPriorityOrder() {
96+ let vm = LiveTailViewModel()
97+ // p_timestamp should take priority
98+ let record: LogRecord = [
99+ "p_timestamp": .string("2024-01-01T00:00:00Z"),
100+ "timestamp": .string("2024-12-31T23:59:59Z")
101+ ]
102+ let date = vm.parseTimestamp(from: record)
103+ XCTAssertNotNil(date)
104+
105+ let calendar = Calendar.current
106+ let components = calendar.dateComponents(in: TimeZone(identifier: "UTC")!, from: date!)
107+ XCTAssertEqual(components.year, 2024)
108+ XCTAssertEqual(components.month, 1)
109+ }
110+
111+ func testParseTimestampNoTimestampField() {
112+ let vm = LiveTailViewModel()
113+ let record: LogRecord = ["message": .string("no timestamp")]
114+ let date = vm.parseTimestamp(from: record)
115+ XCTAssertNil(date)
116+ }
117+
118+ func testParseTimestampNonStringValue() {
119+ let vm = LiveTailViewModel()
120+ let record: LogRecord = ["p_timestamp": .int(1718446245)]
121+ let date = vm.parseTimestamp(from: record)
122+ XCTAssertNil(date)
123+ }
124+
125+ func testParseTimestampInvalidString() {
126+ let vm = LiveTailViewModel()
127+ let record: LogRecord = ["p_timestamp": .string("not-a-date")]
128+ let date = vm.parseTimestamp(from: record)
129+ XCTAssertNil(date)
130+ }
131+
132+ // MARK: - Summary building
133+
134+ func testBuildSummaryWithLevelAndMessage() {
135+ let vm = LiveTailViewModel()
136+ let record: LogRecord = [
137+ "level": .string("info"),
138+ "message": .string("Server started")
139+ ]
140+ let summary = vm.buildSummary(from: record)
141+ XCTAssertTrue(summary.contains("[info]"))
142+ XCTAssertTrue(summary.contains("Server started"))
143+ }
144+
145+ func testBuildSummaryAlternateFieldNames() {
146+ let vm = LiveTailViewModel()
147+ let record: LogRecord = [
148+ "severity": .string("warn"),
149+ "msg": .string("Disk almost full")
150+ ]
151+ let summary = vm.buildSummary(from: record)
152+ XCTAssertTrue(summary.contains("[warn]"))
153+ XCTAssertTrue(summary.contains("Disk almost full"))
154+ }
155+
156+ func testBuildSummaryFallbackToScalarFields() {
157+ let vm = LiveTailViewModel()
158+ let record: LogRecord = [
159+ "status": .int(200),
160+ "path": .string("/api/health")
161+ ]
162+ let summary = vm.buildSummary(from: record)
163+ XCTAssertFalse(summary.isEmpty)
164+ }
165+
166+ func testBuildSummaryEmptyRecord() {
167+ let vm = LiveTailViewModel()
168+ let record: LogRecord = [:]
169+ let summary = vm.buildSummary(from: record)
170+ XCTAssertEqual(summary, "")
171+ }
172+
173+ // MARK: - ViewModel state
174+
175+ func testInitialState() {
176+ let vm = LiveTailViewModel()
177+ XCTAssertFalse(vm.isRunning)
178+ XCTAssertFalse(vm.isPaused)
179+ XCTAssertTrue(vm.entries.isEmpty)
180+ XCTAssertEqual(vm.droppedCount, 0)
181+ XCTAssertNil(vm.lastPollTime)
182+ XCTAssertNil(vm.errorMessage)
183+ }
184+
185+ func testStartWithoutClientIsNoOp() {
186+ let vm = LiveTailViewModel()
187+ vm.start(client: nil, stream: "test")
188+ XCTAssertFalse(vm.isRunning)
189+ }
190+
191+ func testStartWithoutStreamIsNoOp() {
192+ let connection = ServerConnection(name: "test", url: "https://example.com", username: "u", password: "p")
193+ let client = try? ParseableClient(connection: connection)
194+ let vm = LiveTailViewModel()
195+ vm.start(client: client, stream: nil)
196+ XCTAssertFalse(vm.isRunning)
197+ }
198+
199+ func testTogglePause() {
200+ let vm = LiveTailViewModel()
201+ XCTAssertFalse(vm.isPaused)
202+ vm.togglePause()
203+ XCTAssertTrue(vm.isPaused)
204+ vm.togglePause()
205+ XCTAssertFalse(vm.isPaused)
206+ }
207+
208+ func testClearResetsState() {
209+ let vm = LiveTailViewModel()
210+ vm.droppedCount = 42
211+ vm.clear()
212+ XCTAssertTrue(vm.entries.isEmpty)
213+ XCTAssertEqual(vm.droppedCount, 0)
214+ }
215+
216+ func testFilteredEntriesEmptyFilter() {
217+ let vm = LiveTailViewModel()
218+ vm.filterText = ""
219+ XCTAssertEqual(vm.filteredEntries.count, vm.entries.count)
220+ }
221+
222+ func testStopResetsRunningState() {
223+ let connection = ServerConnection(name: "test", url: "https://example.com", username: "u", password: "p")
224+ let client = try? ParseableClient(connection: connection)
225+ let vm = LiveTailViewModel()
226+ vm.start(client: client, stream: "test-stream")
227+ XCTAssertTrue(vm.isRunning)
228+ vm.stop()
229+ XCTAssertFalse(vm.isRunning)
230+ XCTAssertFalse(vm.isPaused)
231+ }
232+
233+ func testStartTwiceIsNoOp() {
234+ let connection = ServerConnection(name: "test", url: "https://example.com", username: "u", password: "p")
235+ let client = try? ParseableClient(connection: connection)
236+ let vm = LiveTailViewModel()
237+ vm.start(client: client, stream: "test-stream")
238+ XCTAssertTrue(vm.isRunning)
239+ // Starting again while running should be a no-op
240+ vm.start(client: client, stream: "other-stream")
241+ XCTAssertTrue(vm.isRunning)
242+ vm.stop()
243+ }
244+
245+ func testDisplayedCountMatchesFilteredEntries() {
246+ let vm = LiveTailViewModel()
247+ XCTAssertEqual(vm.displayedCount, 0)
248+ XCTAssertEqual(vm.entryCount, 0)
249+ }
250+
251+ func testClearResetsFingerprintSet() {
252+ let vm = LiveTailViewModel()
253+ // After clear, entries and dropped count should reset
254+ vm.droppedCount = 10
255+ vm.clear()
256+ XCTAssertTrue(vm.entries.isEmpty)
257+ XCTAssertEqual(vm.droppedCount, 0)
258+ }
259+
260+ func testStopInvalidatesState() {
261+ let vm = LiveTailViewModel()
262+ // Simulate paused state
263+ vm.togglePause()
264+ XCTAssertTrue(vm.isPaused)
265+ // Stop should reset both running and paused
266+ vm.stop()
267+ XCTAssertFalse(vm.isRunning)
268+ XCTAssertFalse(vm.isPaused)
269+ }
270+
271+ func testBuildSummaryWithLogField() {
272+ let vm = LiveTailViewModel()
273+ let record: LogRecord = [
274+ "log_level": .string("debug"),
275+ "log": .string("Processing request")
276+ ]
277+ let summary = vm.buildSummary(from: record)
278+ XCTAssertTrue(summary.contains("[debug]"))
279+ XCTAssertTrue(summary.contains("Processing request"))
280+ }
281+}
ParseableViewerTests/LogTableHelperTests.swiftnew file+63
@@ -0,0 +1,63 @@
1+import XCTest
2+import SwiftUI
3+@testable import ParseableViewer
4+
5+final class LogTableHelperTests: XCTestCase {
6+ // MARK: - columnWidth
7+
8+ func testColumnWidthTimestampFields() {
9+ XCTAssertEqual(columnWidth(for: "p_timestamp"), 200)
10+ XCTAssertEqual(columnWidth(for: "timestamp"), 200)
11+ XCTAssertEqual(columnWidth(for: "@timestamp"), 200)
12+ XCTAssertEqual(columnWidth(for: "time"), 200)
13+ }
14+
15+ func testColumnWidthLevelFields() {
16+ XCTAssertEqual(columnWidth(for: "level"), 80)
17+ XCTAssertEqual(columnWidth(for: "severity"), 80)
18+ XCTAssertEqual(columnWidth(for: "log_level"), 80)
19+ }
20+
21+ func testColumnWidthMessageFields() {
22+ XCTAssertEqual(columnWidth(for: "message"), 400)
23+ XCTAssertEqual(columnWidth(for: "msg"), 400)
24+ XCTAssertEqual(columnWidth(for: "body"), 400)
25+ XCTAssertEqual(columnWidth(for: "log"), 400)
26+ }
27+
28+ func testColumnWidthDefault() {
29+ XCTAssertEqual(columnWidth(for: "custom_field"), 160)
30+ XCTAssertEqual(columnWidth(for: "unknown"), 160)
31+ XCTAssertEqual(columnWidth(for: ""), 160)
32+ }
33+
34+ // MARK: - levelColor
35+
36+ func testLevelColorError() {
37+ XCTAssertEqual(levelColor(for: "error"), .red)
38+ XCTAssertEqual(levelColor(for: "ERROR"), .red)
39+ XCTAssertEqual(levelColor(for: "fatal"), .red)
40+ XCTAssertEqual(levelColor(for: "CRITICAL"), .red)
41+ XCTAssertEqual(levelColor(for: "panic"), .red)
42+ }
43+
44+ func testLevelColorWarn() {
45+ XCTAssertEqual(levelColor(for: "warn"), .orange)
46+ XCTAssertEqual(levelColor(for: "WARNING"), .orange)
47+ }
48+
49+ func testLevelColorInfo() {
50+ XCTAssertEqual(levelColor(for: "info"), .blue)
51+ XCTAssertEqual(levelColor(for: "INFO"), .blue)
52+ }
53+
54+ func testLevelColorDebug() {
55+ XCTAssertEqual(levelColor(for: "debug"), .secondary)
56+ XCTAssertEqual(levelColor(for: "TRACE"), .secondary)
57+ }
58+
59+ func testLevelColorDefault() {
60+ XCTAssertEqual(levelColor(for: "unknown"), .primary)
61+ XCTAssertEqual(levelColor(for: ""), .primary)
62+ }
63+}
ParseableViewerTests/ParseableClientTests.swiftnew file+466
@@ -0,0 +1,466 @@
1+import XCTest
2+@testable import ParseableViewer
3+
4+final class ParseableClientTests: XCTestCase {
5+ // MARK: - Query response parsing
6+
7+ func testQueryResponseWrappedFormat() throws {
8+ let json = """
9+ {"records": [{"message": "hello"}], "fields": ["message"]}
10+ """.data(using: .utf8)!
11+
12+ let wrapped = try JSONDecoder().decode(QueryResponse.self, from: json)
13+ XCTAssertEqual(wrapped.records.count, 1)
14+ XCTAssertEqual(wrapped.records[0]["message"], .string("hello"))
15+ XCTAssertEqual(wrapped.fields, ["message"])
16+ }
17+
18+ func testQueryResponseDirectArrayFormat() throws {
19+ let json = """
20+ [{"level": "info", "msg": "test"}]
21+ """.data(using: .utf8)!
22+
23+ let records = try JSONDecoder().decode([LogRecord].self, from: json)
24+ XCTAssertEqual(records.count, 1)
25+ XCTAssertEqual(records[0]["level"], .string("info"))
26+ XCTAssertEqual(records[0]["msg"], .string("test"))
27+ }
28+
29+ func testQueryResponseEmptyArray() throws {
30+ let json = "[]".data(using: .utf8)!
31+ let records = try JSONDecoder().decode([LogRecord].self, from: json)
32+ XCTAssertTrue(records.isEmpty)
33+ }
34+
35+ func testQueryResponseWrappedEmptyRecords() throws {
36+ let json = """
37+ {"records": [], "fields": ["a", "b"]}
38+ """.data(using: .utf8)!
39+
40+ let wrapped = try JSONDecoder().decode(QueryResponse.self, from: json)
41+ XCTAssertTrue(wrapped.records.isEmpty)
42+ XCTAssertEqual(wrapped.fields, ["a", "b"])
43+ }
44+
45+ // MARK: - Alert config decoding
46+
47+ func testAlertConfigKeyedFormat() throws {
48+ let json = """
49+ {"alerts": [{"name": "high-cpu", "message": "CPU > 90%"}], "version": "v1"}
50+ """.data(using: .utf8)!
51+
52+ let config = try JSONDecoder().decode(AlertConfig.self, from: json)
53+ XCTAssertEqual(config.alerts?.count, 1)
54+ XCTAssertEqual(config.alerts?[0].name, "high-cpu")
55+ XCTAssertEqual(config.version, "v1")
56+ }
57+
58+ func testAlertConfigDirectArrayFormat() throws {
59+ let json = """
60+ [{"name": "disk-full", "message": "Disk usage > 95%"}]
61+ """.data(using: .utf8)!
62+
63+ let config = try JSONDecoder().decode(AlertConfig.self, from: json)
64+ XCTAssertEqual(config.alerts?.count, 1)
65+ XCTAssertEqual(config.alerts?[0].name, "disk-full")
66+ XCTAssertNil(config.version)
67+ }
68+
69+ func testAlertConfigMemberwiseInit() {
70+ let config = AlertConfig(alerts: [], version: nil)
71+ XCTAssertEqual(config.alerts?.count, 0)
72+ XCTAssertNil(config.version)
73+ }
74+
75+ // MARK: - Retention config decoding
76+
77+ func testRetentionConfigArray() throws {
78+ let json = """
79+ [{"description": "Keep 30 days", "duration": "30d", "action": "delete"}]
80+ """.data(using: .utf8)!
81+
82+ let configs = try JSONDecoder().decode([RetentionConfig].self, from: json)
83+ XCTAssertEqual(configs.count, 1)
84+ XCTAssertEqual(configs[0].duration, "30d")
85+ }
86+
87+ func testRetentionConfigSingleObject() throws {
88+ let json = """
89+ {"description": "Keep 7 days", "duration": "7d", "action": "delete"}
90+ """.data(using: .utf8)!
91+
92+ let config = try JSONDecoder().decode(RetentionConfig.self, from: json)
93+ XCTAssertEqual(config.duration, "7d")
94+ XCTAssertEqual(config.action, "delete")
95+ }
96+
97+ // MARK: - Stream schema decoding
98+
99+ func testStreamSchemaKeyedFormat() throws {
100+ let json = """
101+ {"fields": [{"name": "level", "data_type": "Utf8"}]}
102+ """.data(using: .utf8)!
103+
104+ let schema = try JSONDecoder().decode(StreamSchema.self, from: json)
105+ XCTAssertEqual(schema.fields.count, 1)
106+ XCTAssertEqual(schema.fields[0].name, "level")
107+ XCTAssertEqual(schema.fields[0].dataType, "Utf8")
108+ }
109+
110+ func testStreamSchemaDirectArrayFormat() throws {
111+ let json = """
112+ [{"name": "count", "data_type": "Int64"}]
113+ """.data(using: .utf8)!
114+
115+ let schema = try JSONDecoder().decode(StreamSchema.self, from: json)
116+ XCTAssertEqual(schema.fields.count, 1)
117+ XCTAssertEqual(schema.fields[0].dataType, "Int64")
118+ }
119+
120+ func testSchemaFieldObjectDataType() throws {
121+ let json = """
122+ {"name": "tags", "data_type": {"List": "Utf8"}}
123+ """.data(using: .utf8)!
124+
125+ let field = try JSONDecoder().decode(SchemaField.self, from: json)
126+ XCTAssertEqual(field.name, "tags")
127+ XCTAssertFalse(field.dataType.isEmpty)
128+ }
129+
130+ // MARK: - User info decoding
131+
132+ func testUserInfoKeyedFormat() throws {
133+ let json = """
134+ {"id": "admin", "method": "native"}
135+ """.data(using: .utf8)!
136+
137+ let user = try JSONDecoder().decode(UserInfo.self, from: json)
138+ XCTAssertEqual(user.id, "admin")
139+ XCTAssertEqual(user.method, "native")
140+ }
141+
142+ func testUserInfoPlainString() throws {
143+ let json = """
144+ "readonly-user"
145+ """.data(using: .utf8)!
146+
147+ let user = try JSONDecoder().decode(UserInfo.self, from: json)
148+ XCTAssertEqual(user.id, "readonly-user")
149+ XCTAssertNil(user.method)
150+ }
151+
152+ // MARK: - LogStream decoding
153+
154+ func testLogStreamKeyedFormat() throws {
155+ let json = """
156+ {"name": "backend-logs"}
157+ """.data(using: .utf8)!
158+
159+ let stream = try JSONDecoder().decode(LogStream.self, from: json)
160+ XCTAssertEqual(stream.name, "backend-logs")
161+ }
162+
163+ func testLogStreamPlainString() throws {
164+ let json = """
165+ "frontend-logs"
166+ """.data(using: .utf8)!
167+
168+ let stream = try JSONDecoder().decode(LogStream.self, from: json)
169+ XCTAssertEqual(stream.name, "frontend-logs")
170+ }
171+
172+ // MARK: - ServerAbout decoding
173+
174+ func testServerAboutPartialFields() throws {
175+ let json = """
176+ {"version": "1.0.0", "mode": "standalone"}
177+ """.data(using: .utf8)!
178+
179+ let about = try JSONDecoder().decode(ServerAbout.self, from: json)
180+ XCTAssertEqual(about.version, "1.0.0")
181+ XCTAssertEqual(about.mode, "standalone")
182+ XCTAssertNil(about.commit)
183+ XCTAssertNil(about.grpcPort)
184+ }
185+
186+ func testServerAboutEmptyObject() throws {
187+ let json = "{}".data(using: .utf8)!
188+ let about = try JSONDecoder().decode(ServerAbout.self, from: json)
189+ XCTAssertNil(about.version)
190+ XCTAssertNil(about.mode)
191+ }
192+
193+ // MARK: - Auth header
194+
195+ func testClientInitFromConnection() throws {
196+ let connection = ServerConnection(
197+ name: "test",
198+ url: "https://example.com",
199+ username: "admin",
200+ password: "secret"
201+ )
202+ let client = try ParseableClient(connection: connection)
203+ XCTAssertEqual(client.username, "admin")
204+ XCTAssertEqual(client.baseURL.absoluteString, "https://example.com")
205+ }
206+
207+ func testClientInitInvalidURL() {
208+ let connection = ServerConnection(
209+ name: "test",
210+ url: "",
211+ username: "admin",
212+ password: "secret"
213+ )
214+ XCTAssertThrowsError(try ParseableClient(connection: connection))
215+ }
216+
217+ // MARK: - ParseableError
218+
219+ func testServerErrorDescription() {
220+ let error = ParseableError.serverError(500, "Internal Server Error")
221+ XCTAssertTrue(error.errorDescription?.contains("500") ?? false)
222+ }
223+
224+ func testUnauthorizedDescription() {
225+ let error = ParseableError.unauthorized
226+ XCTAssertTrue(error.errorDescription?.contains("Authentication") ?? false)
227+ }
228+
229+ func testNotConnectedDescription() {
230+ let error = ParseableError.notConnected
231+ XCTAssertTrue(error.errorDescription?.contains("Not connected") ?? false)
232+ }
233+
234+ // MARK: - User-friendly error messages
235+
236+ func testUserFriendlyMessageForUnauthorized() {
237+ let msg = ParseableError.userFriendlyMessage(for: ParseableError.unauthorized)
238+ XCTAssertTrue(msg.contains("username"))
239+ XCTAssertTrue(msg.contains("password"))
240+ }
241+
242+ func testUserFriendlyMessageForServerError() {
243+ let msg = ParseableError.userFriendlyMessage(for: ParseableError.serverError(500, "fail"))
244+ XCTAssertTrue(msg.contains("500"))
245+ XCTAssertTrue(msg.contains("Parseable"))
246+ }
247+
248+ func testUserFriendlyMessageForInvalidURL() {
249+ let msg = ParseableError.userFriendlyMessage(for: ParseableError.invalidURL)
250+ XCTAssertTrue(msg.lowercased().contains("url"))
251+ }
252+
253+ func testUserFriendlyMessageForNetworkTimeout() {
254+ let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut, userInfo: nil)
255+ let msg = ParseableError.userFriendlyMessage(for: error)
256+ XCTAssertTrue(msg.lowercased().contains("timed out"))
257+ }
258+
259+ func testUserFriendlyMessageForNoInternet() {
260+ let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet, userInfo: nil)
261+ let msg = ParseableError.userFriendlyMessage(for: error)
262+ XCTAssertTrue(msg.lowercased().contains("internet") || msg.lowercased().contains("network"))
263+ }
264+
265+ func testUserFriendlyMessageForCannotFindHost() {
266+ let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotFindHost, userInfo: nil)
267+ let msg = ParseableError.userFriendlyMessage(for: error)
268+ XCTAssertTrue(msg.lowercased().contains("server") || msg.lowercased().contains("find"))
269+ }
270+
271+ func testUserFriendlyMessageForCannotConnect() {
272+ let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotConnectToHost, userInfo: nil)
273+ let msg = ParseableError.userFriendlyMessage(for: error)
274+ XCTAssertTrue(msg.lowercased().contains("connect"))
275+ }
276+
277+ func testUserFriendlyMessageForSSLError() {
278+ let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorSecureConnectionFailed, userInfo: nil)
279+ let msg = ParseableError.userFriendlyMessage(for: error)
280+ XCTAssertTrue(msg.lowercased().contains("ssl") || msg.lowercased().contains("tls"))
281+ }
282+
283+ func testUserFriendlyMessageForGenericError() {
284+ let error = NSError(domain: "custom", code: 42, userInfo: [NSLocalizedDescriptionKey: "Something broke"])
285+ let msg = ParseableError.userFriendlyMessage(for: error)
286+ XCTAssertEqual(msg, "Something broke")
287+ }
288+
289+ // MARK: - Stream name validation
290+
291+ func testValidStreamName() {
292+ XCTAssertNil(SidebarView.validateStreamName("my-stream_v2.0"))
293+ }
294+
295+ func testStreamNameWithSpaces() {
296+ let error = SidebarView.validateStreamName("my stream")
297+ XCTAssertNotNil(error)
298+ XCTAssertTrue(error?.contains("letters") ?? false)
299+ }
300+
301+ func testStreamNameTooLong() {
302+ let name = String(repeating: "a", count: 256)
303+ let error = SidebarView.validateStreamName(name)
304+ XCTAssertNotNil(error)
305+ XCTAssertTrue(error?.contains("255") ?? false)
306+ }
307+
308+ func testStreamNameStartsWithDot() {
309+ let error = SidebarView.validateStreamName(".hidden")
310+ XCTAssertNotNil(error)
311+ XCTAssertTrue(error?.contains("start") ?? false)
312+ }
313+
314+ func testStreamNameStartsWithHyphen() {
315+ let error = SidebarView.validateStreamName("-bad")
316+ XCTAssertNotNil(error)
317+ XCTAssertTrue(error?.contains("start") ?? false)
318+ }
319+
320+ func testStreamNameWithSpecialChars() {
321+ XCTAssertNotNil(SidebarView.validateStreamName("stream@1"))
322+ XCTAssertNotNil(SidebarView.validateStreamName("stream/path"))
323+ XCTAssertNotNil(SidebarView.validateStreamName("stream name"))
324+ }
325+
326+ func testStreamNameMaxLength() {
327+ let name = String(repeating: "a", count: 255)
328+ XCTAssertNil(SidebarView.validateStreamName(name))
329+ }
330+
331+ // MARK: - SQL identifier escaping
332+
333+ func testEscapeSQLIdentifierSimple() {
334+ XCTAssertEqual(QueryViewModel.escapeSQLIdentifier("logs"), "\"logs\"")
335+ }
336+
337+ func testEscapeSQLIdentifierWithQuotes() {
338+ XCTAssertEqual(QueryViewModel.escapeSQLIdentifier("my\"stream"), "\"my\"\"stream\"")
339+ }
340+
341+ func testEscapeSQLIdentifierWithSpaces() {
342+ XCTAssertEqual(QueryViewModel.escapeSQLIdentifier("my stream"), "\"my stream\"")
343+ }
344+
345+ // MARK: - CSV export
346+
347+ func testBuildCSVEmpty() {
348+ XCTAssertEqual(QueryViewModel.buildCSV(records: [], columns: []), "")
349+ }
350+
351+ func testBuildCSVWithData() {
352+ let records: [LogRecord] = [
353+ ["level": .string("info"), "msg": .string("hello")]
354+ ]
355+ let csv = QueryViewModel.buildCSV(records: records, columns: ["level", "msg"])
356+ XCTAssertTrue(csv.hasPrefix("level,msg\n"))
357+ XCTAssertTrue(csv.contains("info"))
358+ XCTAssertTrue(csv.contains("hello"))
359+ }
360+
361+ func testBuildCSVEscapesCommas() {
362+ let records: [LogRecord] = [
363+ ["msg": .string("hello, world")]
364+ ]
365+ let csv = QueryViewModel.buildCSV(records: records, columns: ["msg"])
366+ XCTAssertTrue(csv.contains("\"hello, world\""))
367+ }
368+
369+ func testBuildCSVEscapesQuotes() {
370+ let records: [LogRecord] = [
371+ ["msg": .string("say \"hi\"")]
372+ ]
373+ let csv = QueryViewModel.buildCSV(records: records, columns: ["msg"])
374+ XCTAssertTrue(csv.contains("\"say \"\"hi\"\"\""))
375+ }
376+
377+ // MARK: - Malformed response decoding
378+
379+ func testQueryResponseMalformedThrows() throws {
380+ // Malformed data that is neither QueryResponse nor [LogRecord]
381+ let data = "not json at all".data(using: .utf8)!
382+
383+ // Neither format should decode
384+ XCTAssertNil(try? JSONDecoder().decode(QueryResponse.self, from: data))
385+ XCTAssertNil(try? JSONDecoder().decode([LogRecord].self, from: data))
386+ }
387+
388+ func testRetentionConfigEmptyDataReturnsEmpty() throws {
389+ // Empty data should be treated as no retention (tested via the Data.isEmpty check)
390+ let emptyData = Data()
391+ // JSONDecoder would fail on empty data
392+ XCTAssertThrowsError(try JSONDecoder().decode([RetentionConfig].self, from: emptyData))
393+ }
394+
395+ func testAlertConfigMalformedThrows() {
396+ // Completely invalid JSON should fail AlertConfig decoding
397+ let data = "<<<not json>>>".data(using: .utf8)!
398+ XCTAssertThrowsError(try JSONDecoder().decode(AlertConfig.self, from: data))
399+ }
400+
401+ func testRetentionConfigMalformedThrows() {
402+ // A plain string is not valid as RetentionConfig or [RetentionConfig]
403+ let data = "\"just a string\"".data(using: .utf8)!
404+ XCTAssertNil(try? JSONDecoder().decode([RetentionConfig].self, from: data))
405+ XCTAssertNil(try? JSONDecoder().decode(RetentionConfig.self, from: data))
406+ }
407+
408+ // MARK: - Stream name validation (empty)
409+
410+ func testStreamNameEmpty() {
411+ let error = SidebarView.validateStreamName("")
412+ XCTAssertNotNil(error)
413+ XCTAssertTrue(error?.lowercased().contains("empty") ?? false)
414+ }
415+
416+ func testStreamNameWhitespaceOnly() {
417+ let error = SidebarView.validateStreamName(" ")
418+ XCTAssertNotNil(error)
419+ XCTAssertTrue(error?.lowercased().contains("empty") ?? false)
420+ }
421+
422+ // MARK: - URL scheme validation
423+
424+ func testValidateURLAcceptsHTTPS() {
425+ // ConnectionSheet.validateURL is private, so we test via ServerConnection.baseURL
426+ let conn = ServerConnection(name: "t", url: "https://example.com", username: "u", password: "p")
427+ XCTAssertNotNil(conn.baseURL)
428+ XCTAssertEqual(conn.baseURL?.scheme, "https")
429+ }
430+
431+ func testValidateURLAcceptsHTTP() {
432+ let conn = ServerConnection(name: "t", url: "http://example.com", username: "u", password: "p")
433+ XCTAssertNotNil(conn.baseURL)
434+ XCTAssertEqual(conn.baseURL?.scheme, "http")
435+ }
436+
437+ // MARK: - CSV export edge cases
438+
439+ func testBuildCSVEscapesNewlines() {
440+ let records: [LogRecord] = [
441+ ["msg": .string("line1\nline2")]
442+ ]
443+ let csv = QueryViewModel.buildCSV(records: records, columns: ["msg"])
444+ // Newlines in values should be wrapped in quotes
445+ XCTAssertTrue(csv.contains("\"line1\nline2\""))
446+ }
447+
448+ func testBuildCSVMissingColumn() {
449+ let records: [LogRecord] = [
450+ ["level": .string("info")]
451+ ]
452+ let csv = QueryViewModel.buildCSV(records: records, columns: ["level", "msg"])
453+ // Missing column value should produce empty field
454+ let lines = csv.components(separatedBy: "\n")
455+ XCTAssertTrue(lines[1].hasSuffix(",") || lines[1].components(separatedBy: ",").count == 2)
456+ }
457+
458+ func testBuildCSVEscapesCarriageReturn() {
459+ let records: [LogRecord] = [
460+ ["msg": .string("line1\r\nline2")]
461+ ]
462+ let csv = QueryViewModel.buildCSV(records: records, columns: ["msg"])
463+ // Carriage returns in values should be wrapped in quotes per RFC 4180
464+ XCTAssertTrue(csv.contains("\"line1\r\nline2\""))
465+ }
466+}
ParseableViewerTests/QueryViewModelTests.swiftnew file+205
@@ -0,0 +1,205 @@
1+import XCTest
2+@testable import ParseableViewer
3+
4+final class QueryViewModelTests: XCTestCase {
5+ // MARK: - Column extraction
6+
7+ func testExtractColumnsOrdersPriorityFieldsFirst() {
8+ let vm = QueryViewModel()
9+ let records: [LogRecord] = [
10+ [
11+ "custom_field": .string("val"),
12+ "message": .string("hello"),
13+ "p_timestamp": .string("2024-01-01"),
14+ "level": .string("info"),
15+ "zebra": .string("z")
16+ ]
17+ ]
18+
19+ // Simulate the column extraction by running a query with mock data
20+ // Since we can't easily mock the client, test the public interface
21+ // by verifying filteredResults and columns after setting them directly
22+ vm.results = records
23+ vm.columns = vm.extractColumns(from: records)
24+
25+ // Priority fields should come first
26+ XCTAssertEqual(vm.columns[0], "p_timestamp")
27+ XCTAssertEqual(vm.columns[1], "level")
28+ XCTAssertEqual(vm.columns[2], "message")
29+
30+ // Remaining fields sorted alphabetically
31+ let remaining = Array(vm.columns.dropFirst(3))
32+ XCTAssertEqual(remaining, remaining.sorted())
33+ }
34+
35+ func testExtractColumnsEmpty() {
36+ let vm = QueryViewModel()
37+ let cols = vm.extractColumns(from: [])
38+ XCTAssertTrue(cols.isEmpty)
39+ }
40+
41+ func testExtractColumnsMergesAcrossRecords() {
42+ let vm = QueryViewModel()
43+ let records: [LogRecord] = [
44+ ["a": .int(1), "b": .int(2)],
45+ ["b": .int(3), "c": .int(4)]
46+ ]
47+ let cols = vm.extractColumns(from: records)
48+ XCTAssertEqual(Set(cols), Set(["a", "b", "c"]))
49+ XCTAssertEqual(cols.count, 3) // no duplicates
50+ }
51+
52+ // MARK: - Filtered results
53+
54+ func testFilteredResultsEmptyFilter() {
55+ let vm = QueryViewModel()
56+ vm.results = [
57+ ["message": .string("hello")],
58+ ["message": .string("world")]
59+ ]
60+ vm.filterText = ""
61+ XCTAssertEqual(vm.filteredResults.count, 2)
62+ }
63+
64+ func testFilteredResultsMatchesContent() {
65+ let vm = QueryViewModel()
66+ vm.results = [
67+ ["message": .string("hello world")],
68+ ["message": .string("goodbye moon")],
69+ ["message": .string("Hello there")]
70+ ]
71+ vm.filterText = "hello"
72+ XCTAssertEqual(vm.filteredResults.count, 2) // case-insensitive
73+ }
74+
75+ func testFilteredResultsNoMatch() {
76+ let vm = QueryViewModel()
77+ vm.results = [
78+ ["message": .string("hello")],
79+ ["message": .string("world")]
80+ ]
81+ vm.filterText = "xyz"
82+ XCTAssertEqual(vm.filteredResults.count, 0)
83+ }
84+
85+ // MARK: - CSV export
86+
87+ func testExportCSVBasic() {
88+ let vm = QueryViewModel()
89+ vm.results = [
90+ ["name": .string("Alice"), "age": .int(30)],
91+ ["name": .string("Bob"), "age": .int(25)]
92+ ]
93+ vm.columns = ["name", "age"]
94+ let csv = vm.exportAsCSV()
95+
96+ let lines = csv.components(separatedBy: "\n").filter { !$0.isEmpty }
97+ XCTAssertEqual(lines.count, 3) // header + 2 rows
98+ XCTAssertEqual(lines[0], "name,age")
99+ XCTAssertEqual(lines[1], "Alice,30")
100+ XCTAssertEqual(lines[2], "Bob,25")
101+ }
102+
103+ func testExportCSVEscaping() {
104+ let vm = QueryViewModel()
105+ vm.results = [
106+ ["msg": .string("hello, world"), "note": .string("has \"quotes\"")]
107+ ]
108+ vm.columns = ["msg", "note"]
109+ let csv = vm.exportAsCSV()
110+
111+ let lines = csv.components(separatedBy: "\n").filter { !$0.isEmpty }
112+ XCTAssertEqual(lines.count, 2)
113+ XCTAssertTrue(lines[1].contains("\"hello, world\""))
114+ XCTAssertTrue(lines[1].contains("\"has \"\"quotes\"\"\""))
115+ }
116+
117+ func testExportCSVEmpty() {
118+ let vm = QueryViewModel()
119+ XCTAssertEqual(vm.exportAsCSV(), "")
120+ }
121+
122+ // MARK: - JSON export
123+
124+ func testExportJSONEmpty() {
125+ let vm = QueryViewModel()
126+ XCTAssertEqual(vm.exportAsJSON(), "[]")
127+ }
128+
129+ func testExportJSONNotEmpty() {
130+ let vm = QueryViewModel()
131+ vm.results = [["key": .string("value")]]
132+ let json = vm.exportAsJSON()
133+ XCTAssertTrue(json.contains("\"key\""))
134+ XCTAssertTrue(json.contains("\"value\""))
135+ }
136+
137+ // MARK: - Time range
138+
139+ func testTimeRangeOptionsAllHaveValues() {
140+ for option in QueryViewModel.TimeRangeOption.allCases {
141+ let range = option.dateRange()
142+ XCTAssertLessThanOrEqual(range.start, range.end, "Option \(option.rawValue) has invalid range")
143+ }
144+ }
145+
146+ func testTimeRangePresets() {
147+ let now = Date()
148+ let fiveMin = QueryViewModel.TimeRangeOption.last5Min.dateRange()
149+ let diff = now.timeIntervalSince(fiveMin.start)
150+ // Should be roughly 5 minutes (300 seconds) +/- 2 seconds for test execution time
151+ XCTAssertTrue(abs(diff - 300) < 2, "Last 5 min diff was \(diff)")
152+ }
153+
154+ // MARK: - Query history
155+
156+ func testSetDefaultQuery() {
157+ let vm = QueryViewModel()
158+ vm.setDefaultQuery(stream: "test_stream")
159+ XCTAssertTrue(vm.sqlQuery.contains("test_stream"))
160+ XCTAssertTrue(vm.sqlQuery.contains("SELECT"))
161+ }
162+
163+ func testSetDefaultQueryDoesNotOverwrite() {
164+ let vm = QueryViewModel()
165+ vm.sqlQuery = "SELECT 1"
166+ vm.setDefaultQuery(stream: "test_stream")
167+ XCTAssertEqual(vm.sqlQuery, "SELECT 1")
168+ }
169+
170+ // MARK: - Query cancellation
171+
172+ @MainActor
173+ func testCancelQuerySetsState() {
174+ let vm = QueryViewModel()
175+ // Simulate loading state
176+ vm.isLoading = true
177+ vm.cancelQuery()
178+ XCTAssertFalse(vm.isLoading)
179+ XCTAssertEqual(vm.errorMessage, "Query cancelled")
180+ }
181+
182+ @MainActor
183+ func testCancelQueryWhenNotLoading() {
184+ let vm = QueryViewModel()
185+ vm.cancelQuery()
186+ // Should still set the message but not crash
187+ XCTAssertFalse(vm.isLoading)
188+ XCTAssertEqual(vm.errorMessage, "Query cancelled")
189+ }
190+
191+ // MARK: - Default query uses escaped identifiers
192+
193+ func testSetDefaultQueryEscapesStreamName() {
194+ let vm = QueryViewModel()
195+ vm.setDefaultQuery(stream: "my-stream")
196+ XCTAssertTrue(vm.sqlQuery.contains("\"my-stream\""))
197+ }
198+
199+ func testSetDefaultQueryEscapesQuotesInStreamName() {
200+ let vm = QueryViewModel()
201+ vm.setDefaultQuery(stream: "stream\"name")
202+ XCTAssertTrue(vm.sqlQuery.contains("\"stream\"\"name\""))
203+ }
204+}
205+
ParseableViewerTests/ServerConnectionTests.swiftnew file+80
@@ -0,0 +1,80 @@
1+import XCTest
2+@testable import ParseableViewer
3+
4+final class ServerConnectionTests: XCTestCase {
5+ // MARK: - URL parsing
6+
7+ func testBaseURLWithHTTPS() {
8+ let conn = ServerConnection(name: "test", url: "https://example.com", username: "admin", password: "pass")
9+ XCTAssertEqual(conn.baseURL?.absoluteString, "https://example.com")
10+ }
11+
12+ func testBaseURLWithHTTP() {
13+ let conn = ServerConnection(name: "test", url: "http://localhost:8000", username: "admin", password: "pass")
14+ XCTAssertEqual(conn.baseURL?.absoluteString, "http://localhost:8000")
15+ }
16+
17+ func testBaseURLAddsHTTPS() {
18+ let conn = ServerConnection(name: "test", url: "example.com", username: "admin", password: "pass")
19+ XCTAssertEqual(conn.baseURL?.absoluteString, "https://example.com")
20+ }
21+
22+ func testBaseURLStripsTrailingSlashes() {
23+ let conn = ServerConnection(name: "test", url: "https://example.com///", username: "admin", password: "pass")
24+ XCTAssertEqual(conn.baseURL?.absoluteString, "https://example.com")
25+ }
26+
27+ func testBaseURLTrimsWhitespace() {
28+ let conn = ServerConnection(name: "test", url: " https://example.com ", username: "admin", password: "pass")
29+ XCTAssertEqual(conn.baseURL?.absoluteString, "https://example.com")
30+ }
31+
32+ func testBaseURLWithPort() {
33+ let conn = ServerConnection(name: "test", url: "localhost:8000", username: "admin", password: "pass")
34+ XCTAssertEqual(conn.baseURL?.absoluteString, "https://localhost:8000")
35+ }
36+
37+ // MARK: - Codable (password excluded)
38+
39+ func testEncodingExcludesPassword() throws {
40+ let conn = ServerConnection(name: "test", url: "https://example.com", username: "admin", password: "secret")
41+ let data = try JSONEncoder().encode(conn)
42+ let json = String(data: data, encoding: .utf8)!
43+ XCTAssertFalse(json.contains("secret"), "Password should not be in encoded JSON")
44+ XCTAssertTrue(json.contains("admin"))
45+ XCTAssertTrue(json.contains("example.com"))
46+ }
47+
48+ // MARK: - Hashable/Equatable
49+
50+ func testEquality() {
51+ let id = UUID()
52+ let a = ServerConnection(id: id, name: "A", url: "url", username: "user", password: "pass")
53+ let b = ServerConnection(id: id, name: "A", url: "url", username: "user", password: "pass")
54+ XCTAssertEqual(a, b)
55+ }
56+
57+ func testInequality() {
58+ let a = ServerConnection(name: "A", url: "url", username: "user", password: "pass")
59+ let b = ServerConnection(name: "B", url: "url", username: "user", password: "pass")
60+ XCTAssertNotEqual(a, b)
61+ }
62+
63+ // MARK: - BaseURL host validation
64+
65+ func testBaseURLEmptyReturnsNil() {
66+ let conn = ServerConnection(name: "test", url: "", username: "admin", password: "pass")
67+ XCTAssertNil(conn.baseURL)
68+ }
69+
70+ func testBaseURLWhitespaceOnlyReturnsNil() {
71+ let conn = ServerConnection(name: "test", url: " ", username: "admin", password: "pass")
72+ XCTAssertNil(conn.baseURL)
73+ }
74+
75+ func testBaseURLSchemeOnlyReturnsNil() {
76+ // "https://" has no host, should return nil
77+ let conn = ServerConnection(name: "test", url: "https://", username: "admin", password: "pass")
78+ XCTAssertNil(conn.baseURL)
79+ }
80+}
README.mdnew file+145
@@ -0,0 +1,145 @@
1+# Parseable Viewer
2+
3+A native macOS log viewer for [Parseable](https://www.parseable.com/), built with Swift and SwiftUI.
4+
5+Browse log streams, run SQL queries, tail logs in real time, and inspect server configuration — all from a lightweight desktop app with zero external dependencies.
6+
7+## Features
8+
9+- **SQL Query Editor** — Write and execute SQL queries against any log stream with configurable time ranges (presets or custom), result filtering, column sorting, and query history
10+- **Live Tail** — Stream logs in near-real-time via HTTP polling with automatic deduplication, pause/resume, and a configurable entry buffer
11+- **Stream Management** — List, create, and delete log streams; view schema, ingestion stats, storage usage, retention policies, and partitioning config
12+- **Multiple Connections** — Save and switch between Parseable server connections with Keychain-backed password storage
13+- **Export** — Export query results as CSV or JSON via the system save dialog
14+- **Saved Queries** — Bookmark frequently-used SQL queries per stream for quick access from the sidebar
15+- **Alerts & Users** — View configured alert rules and user accounts on the connected server
16+- **Server Info** — Health status, version, deployment mode, storage backend, and update availability at a glance
17+
18+## Requirements
19+
20+- macOS 14.0+ (Sonoma)
21+- Xcode 16+
22+- [XcodeGen](https://github.com/yonaskolb/XcodeGen)
23+
24+## Build
25+
26+```bash
27+# Install XcodeGen (one-time)
28+brew install xcodegen
29+
30+# Generate the Xcode project and build
31+xcodegen generate
32+xcodebuild -project ParseableViewer.xcodeproj \
33+ -scheme ParseableViewer \
34+ -configuration Release \
35+ build
36+```
37+
38+The built app is located at `build/Build/Products/Release/Parseable Viewer.app`.
39+
40+To run tests:
41+
42+```bash
43+xcodebuild test \
44+ -project ParseableViewer.xcodeproj \
45+ -scheme ParseableViewer \
46+ -configuration Debug \
47+ -destination 'platform=macOS'
48+```
49+
50+## CI
51+
52+GitHub Actions builds and tests on every push and PR via [`.github/workflows/build.yml`](.github/workflows/build.yml). The pipeline:
53+
54+1. Builds for both `arm64` and `x86_64`
55+2. Runs tests on `arm64`
56+3. Creates a universal binary via `lipo`
57+4. On tagged releases (`v*`), creates a draft GitHub Release with all three zips
58+
59+## Architecture
60+
61+| Layer | Description |
62+|-------|-------------|
63+| **App** | `@main` entry point, `AppState` (central `@Observable` singleton injected via `.environment()`) |
64+| **Models** | `Codable` data types — `JSONValue` (recursive enum for arbitrary JSON), `LogStream`, `ServerAbout`, `AlertConfig`, etc. |
65+| **Services** | `ParseableClient` (stateless HTTP client, Basic Auth, `URLSession` async/await), `ConnectionStore` (UserDefaults + Keychain persistence) |
66+| **ViewModels** | `QueryViewModel` (SQL execution, filtering, export, history), `LiveTailViewModel` (timer-based polling, deduplication) |
67+| **Views** | SwiftUI views — `NavigationSplitView` sidebar + tabbed detail pane |
68+
69+### Key design decisions
70+
71+- **No external dependencies** — the app uses only Apple frameworks (`Foundation`, `SwiftUI`, `Network`, `Security`)
72+- **Defensive decoding** — models use `try?` for optional API fields to tolerate version differences across Parseable releases
73+- **Passwords in Keychain** — `ServerConnection` excludes `password` from its `Codable` conformance; the password is stored/loaded via `KeychainService` separately from the UserDefaults data
74+- **Live tail via HTTP polling** — Parseable's native live tail uses gRPC Arrow Flight streaming; this app approximates it with a sliding time window and content-based deduplication (FNV-1a fingerprinting)
75+
76+### Parseable API endpoints used
77+
78+All under `/api/v1/`. Auth: `Authorization: Basic <base64(user:pass)>`.
79+
80+| Method | Path | Purpose |
81+|--------|------|---------|
82+| HEAD | `/liveness` | Health check |
83+| GET | `/about` | Server version, mode, storage info |
84+| GET | `/logstream` | List all streams |
85+| PUT/DELETE | `/logstream/{name}` | Create/delete stream |
86+| GET | `/logstream/{name}/schema` | Field names and types |
87+| GET | `/logstream/{name}/stats` | Ingestion/storage statistics |
88+| GET | `/logstream/{name}/info` | Creation time, partitioning config |
89+| POST | `/query` | SQL query with time range |
90+| GET | `/alerts` | Alert rules |
91+| GET | `/logstream/{name}/retention` | Retention policy |
92+| GET | `/user` | List users |
93+
94+## Project structure
95+
96+```
97+ParseableViewer/
98+ App/
99+ ParseableViewerApp.swift # @main, window/menu setup
100+ AppState.swift # Central observable: connections, streams, navigation
101+ Models/
102+ JSONValue.swift # Recursive JSON enum (null|bool|int|double|string|array|object)
103+ LogStream.swift # Stream, schema, stats, info models
104+ ServerInfo.swift # ServerAbout, RetentionConfig, UserInfo
105+ AlertModels.swift # AlertConfig, AlertRule, AlertTarget
106+ ServerConnection.swift # Connection model with Keychain integration
107+ Services/
108+ ParseableClient.swift # HTTP client + ParseableError
109+ ConnectionStore.swift # UserDefaults persistence, SavedQuery store
110+ KeychainService.swift # Keychain read/write/delete
111+ ViewModels/
112+ QueryViewModel.swift # Query execution, filtering, export, history
113+ LiveTailViewModel.swift # Polling, deduplication, entry buffer
114+ Views/
115+ ContentView.swift # Root layout: sidebar + detail
116+ SidebarView.swift # Stream list, saved queries, connection switching
117+ QueryView.swift # SQL editor, time picker, results table
118+ LiveTailView.swift # Real-time log streaming UI
119+ StreamDetailView.swift # Schema, stats, retention for selected stream
120+ LogTableView.swift # Sortable log record table with detail pane
121+ LogDetailView.swift # Formatted + raw JSON record inspector
122+ AlertsView.swift # Alert rule listing
123+ UsersView.swift # User listing
124+ ServerInfoView.swift # Server health, version, storage info
125+ ConnectionSheet.swift # Add/edit/test connection dialog
126+ TimeRangePicker.swift # Preset + custom date range selector
127+ SettingsView.swift # Preferences: query defaults, live tail config
128+ Resources/
129+ Assets.xcassets # App icon, accent color
130+```
131+
132+## Keyboard shortcuts
133+
134+| Shortcut | Action |
135+|----------|--------|
136+| `Cmd+Shift+N` | New connection |
137+| `Cmd+R` | Refresh streams |
138+| `Cmd+Enter` | Run query |
139+| `Cmd+P` | Pause/resume live tail |
140+| `Cmd+Delete` | Clear live tail entries |
141+| `Cmd+Ctrl+S` | Toggle sidebar |
142+
143+## License
144+
145+See [LICENSE](LICENSE) for details.
project.ymlnew file+74
@@ -0,0 +1,74 @@
1+name: ParseableViewer
2+options:
3+ bundleIdPrefix: com.parseableviewer
4+ deploymentTarget:
5+ macOS: "14.0"
6+ xcodeVersion: "16.2"
7+ createIntermediateGroups: true
8+ defaultConfig: Release
9+
10+targets:
11+ ParseableViewer:
12+ type: application
13+ platform: macOS
14+ sources:
15+ - path: ParseableViewer
16+ excludes:
17+ - "**/.DS_Store"
18+ settings:
19+ base:
20+ PRODUCT_BUNDLE_IDENTIFIER: com.parseableviewer.app
21+ PRODUCT_NAME: "Parseable Viewer"
22+ PRODUCT_MODULE_NAME: ParseableViewer
23+ MARKETING_VERSION: "1.0.0"
24+ CURRENT_PROJECT_VERSION: 1
25+ SWIFT_VERSION: "5.9"
26+ MACOSX_DEPLOYMENT_TARGET: "14.0"
27+ CODE_SIGN_IDENTITY: "-"
28+ CODE_SIGN_STYLE: Manual
29+ COMBINE_HIDPI_IMAGES: YES
30+ INFOPLIST_FILE: ParseableViewer/Info.plist
31+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
32+ ENABLE_HARDENED_RUNTIME: YES
33+ configs:
34+ Debug:
35+ SWIFT_OPTIMIZATION_LEVEL: "-Onone"
36+ SWIFT_ACTIVE_COMPILATION_CONDITIONS: "DEBUG"
37+ Release:
38+ SWIFT_OPTIMIZATION_LEVEL: "-O"
39+ SWIFT_COMPILATION_MODE: "wholemodule"
40+ entitlements:
41+ path: ParseableViewer/ParseableViewer.entitlements
42+
43+ ParseableViewerTests:
44+ type: bundle.unit-test
45+ platform: macOS
46+ sources:
47+ - path: ParseableViewerTests
48+ excludes:
49+ - "**/.DS_Store"
50+ settings:
51+ base:
52+ SWIFT_VERSION: "5.9"
53+ MACOSX_DEPLOYMENT_TARGET: "14.0"
54+ BUNDLE_LOADER: "$(BUILT_PRODUCTS_DIR)/Parseable Viewer.app/Contents/MacOS/Parseable Viewer"
55+ TEST_HOST: "$(BUNDLE_LOADER)"
56+ dependencies:
57+ - target: ParseableViewer
58+
59+schemes:
60+ ParseableViewer:
61+ build:
62+ targets:
63+ ParseableViewer: all
64+ ParseableViewerTests: [test]
65+ run:
66+ config: Debug
67+ test:
68+ config: Debug
69+ targets:
70+ - ParseableViewerTests
71+ profile:
72+ config: Release
73+ analyze:
74+ config: Debug