git.iko.soy/parseable-macos
on master help sign in
Add Parseable Viewer macOS application (#1)

* Add native macOS Parseable log viewer app Built with Swift/SwiftUI targeting macOS 14+, this app provides a full-featured interface for viewing and querying logs from Parseable servers. Features: - Server connection management with Basic Auth - Log stream browsing, creation, and deletion - SQL query editor with configurable time range picker - Query results table with sortable columns and text filtering - Live tail (polling-based real-time log streaming) - Stream details: schema, stats, info, and retention config - Alert configuration viewer - User management viewer - Server info and health monitoring - Log entry detail view with formatted and raw JSON modes - Export query results as JSON or CSV - Saved queries - Keyboard shortcuts and macOS-native UI patterns CI/CD: - GitHub Actions workflow builds arm64 and x86_64 binaries - Creates universal binary via lipo - Uploads artifacts and creates draft releases on tags Uses XcodeGen for project generation from project.yml. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix query response parsing and enrich server info model - Handle both wrapped {"records":[], "fields":[]} and direct array query response formats from the Parseable API - Add QueryResponse struct for wrapped format - Try new /api/v1/alerts endpoint before legacy per-stream one - Add updateAvailable, latestVersion, llmActive, oidcActive fields to ServerAbout model - Display new server fields in ServerInfoView https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Add CLAUDE.md with project context for Claude Code https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Improve security, correctness, and UX across the app - Store passwords in Keychain instead of UserDefaults (KeychainService) - Fix live tail deduplication with deterministic FNV-1a fingerprinting - Wire up SettingsView @AppStorage values to LiveTailViewModel - Add query history tracking with persistence and UI dropdown - Add truncation warning banner when results hit query limit - Deduplicate column width/level color logic in LogTableView - Optimize column extraction with single-pass Set-based approach - Fix force unwraps in TimeRangePicker date calculations - Fix ConnectionSheet to await connection before dismissing - Add URL validation in ConnectionSheet - Add copy-to-clipboard visual feedback in LogDetailView - Add unit tests for JSONValue, QueryViewModel, ServerConnection, and helpers - Add test target to project.yml and CI workflow https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix bugs, improve performance, correctness, and accessibility High priority: - Fix saved queries not loading SQL into editor (add pendingSavedQuerySQL to AppState) - Fix LogTableView performance: cache sorted records, use index-based identity instead of offset-based enumeration, add rebuildSort() on data/sort changes - Cache filteredResults in QueryViewModel with didSet triggers instead of recomputing on every SwiftUI access - Remove || true from CI test step so test failures break the build - Fix universal binary CI step to exit 1 on lipo failure Medium priority: - URL-encode stream names in all ParseableClient API paths - Apply defaultTimeRange setting from UserDefaults in QueryViewModel.init() - Fix timer data race: mark LiveTailViewModel @MainActor, move isPaused check inside Task block - Fix duplicate log record selection using index-based tracking - Fix getAlerts fallback to only fall back on 404, not all errors - Add accessibility labels to tab bar buttons and status indicators - Use shared levelColor(for:) in LiveTailView instead of duplicate function - Cache DateFormatters as static properties in LiveTailViewModel Low priority: - Add error feedback for file export (alert on write failure) - Remove dead monospacedFont setting from SettingsView - Add time range validation warning when end < start - Add context menu to copy cell values in log table - Add hover tooltip for truncated live tail entry text https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix CI: upgrade to macos-15 runner with Xcode 16 The latest XcodeGen generates project format version 77 which requires Xcode 16+. The macos-14 runner only has Xcode 15 which cannot read this format. Upgrade all CI jobs to macos-15 and explicitly select Xcode 16.2. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix CI: use main scheme for tests to resolve missing test host The ParseableViewerTests scheme doesn't automatically build the host app before running tests, causing "Could not find test host" errors. Switch to -scheme ParseableViewer which builds the app and then runs its associated unit tests. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix CI: add explicit scheme with test action for ParseableViewer XcodeGen's auto-generated ParseableViewer scheme does not include a test action. Add an explicit scheme definition in project.yml that configures the test action with ParseableViewerTests, so `xcodebuild -scheme ParseableViewer test` works correctly. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix CI: split test step into build-for-testing + test-without-building xcodebuild test fails with "Could not find test host" because it runs a pre-flight validation check for TEST_HOST before building. Split into build-for-testing (builds app + test bundle first) then test-without-building (runs against already-built host). Also pin destination to arm64 to avoid ambiguous destination warnings. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix TEST_HOST mismatch: target name vs PRODUCT_NAME with space XcodeGen derived TEST_HOST from the target name "ParseableViewer", producing the path .../ParseableViewer.app/Contents/MacOS/ParseableViewer. But PRODUCT_NAME is "Parseable Viewer" (with space), so the actual app is at .../Parseable Viewer.app/Contents/MacOS/Parseable Viewer. Explicitly set BUNDLE_LOADER and TEST_HOST in the test target settings to use the correct product name. Also fix EXEC_NAME in the universal binary step which had the same no-space mismatch, and simplify the test workflow back to a single xcodebuild test invocation. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Add memberwise initializer to AlertConfig for fallback construction AlertConfig only had init(from:) (Decodable), so the fallback AlertConfig(alerts: [], version: nil) in ParseableClient couldn't compile. Adding an explicit memberwise init fixes the build. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Set PRODUCT_MODULE_NAME to keep Swift module name as ParseableViewer PRODUCT_NAME is "Parseable Viewer" (with space), which causes the auto-derived PRODUCT_MODULE_NAME to be "Parseable_Viewer" (underscore). Test files use @testable import ParseableViewer, so the module name must match. Explicitly setting PRODUCT_MODULE_NAME fixes the "No such module 'ParseableViewer'" error in the test target. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix bugs, improve error feedback, and add test coverage Bug fixes: - ServerInfoView: remove dangling do-block without catch that could crash; show error message when server info fails to load - ParseableClient: replace force-unwrap on UTF-8 encoding with guard Error feedback improvements: - StreamDetailView: report which specific resources failed to load (schema, stats, info, retention) instead of a generic message - LiveTailView: add pulsing activity indicator when streaming, show count of dropped entries when buffer cap is reached Test coverage (3 new test files, ~50 new tests): - ParseableClientTests: query response formats, alert/retention/schema decoding, server about parsing, client initialization - LiveTailViewModelTests: fingerprint determinism/uniqueness, timestamp parsing across field names and formats, summary building, state mgmt - ConnectionStoreTests: active connection ID persistence, connection round-trip, password exclusion from UserDefaults, saved queries Also made LiveTailViewModel.fingerprint/parseTimestamp/buildSummary internal (was private) so they can be tested via @testable import. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Improve accessibility, UX, and robustness across the app - Add VoiceOver accessibility labels to all interactive buttons and form fields across LiveTailView, QueryView, ConnectionSheet, SidebarView, StreamDetailView, AlertsView, and UsersView - Add keyboard shortcuts: Cmd+P for pause/resume in Live Tail, Cmd+Delete for clear entries - Clear password from memory when ConnectionSheet is dismissed - Add user-friendly error messages in ConnectionSheet that distinguish between auth failures, DNS errors, timeouts, and SSL issues - Add connection throttling via guard in AppState.connect() and ConnectionSheet.saveAndConnect() to prevent duplicate requests - Add network reachability monitoring via NWPathMonitor with offline error message - Replace DispatchQueue.main.asyncAfter with Task.sleep in LogDetailView clipboard feedback - Make ContentView tab switching lazy so only the active tab view is instantiated - Debounce LogTableView sort rebuilds with 150ms delay to avoid lag with large result sets - Add retry buttons to error states in AlertsView, UsersView, and StreamDetailView - Detect stream-not-found (404) in StreamDetailView when all API calls fail, showing a clear "stream may have been deleted" message - Show query history limit indication in the history menu when the 50-entry cap is reached https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Harden security, improve error handling, and fix data integrity issues Security: - Fix SQL injection: add escapeSQLIdentifier() that doubles internal quotes before wrapping in double-quotes; used in QueryViewModel and LiveTailViewModel - Restrict URL path encoding character set to exclude /?#[]@!$&'()*+,;= preventing path traversal via special characters in stream names - Add Keychain error logging: SecItemAdd/SecItemDelete return values are now checked and logged via os.Logger instead of silently discarded Data integrity: - Clean up orphaned saved queries when a stream is deleted, preventing stale references in the sidebar - Show warning icon on saved queries whose stream no longer exists - Invalidate URLSession on ParseableClient deinit to release resources Error handling: - Extract userFriendlyMessage(for:) to ParseableError as a shared utility, replacing the private copy in ConnectionSheet - Use friendly error messages in AppState.connect() and refreshStreams() - Add streamLoadError state to AppState so the sidebar can show inline retry when stream list loading fails - Add retry banner in sidebar above the toolbar when streams fail to load UX: - Disable "Done" button in TimeRangePicker when end date is before start - Add "Last 30 days" to SettingsView default time range picker (was missing despite QueryViewModel supporting it) - Display app version and build number in ServerInfoView https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Improve resource management, input validation, performance, and UX - Cancel NWPathMonitor in AppState deinit to prevent background leaks - Validate stream names (alphanumeric, hyphens, underscores, dots; max 255 chars) - Move CSV/JSON export to background thread to avoid UI freezes on large datasets - Add depth limit (10) to JSONValueView recursive rendering to prevent stack overflow - Add depth limit (50) to JSONValue.prettyPrinted() for deeply nested structures - Add query cancellation with Cancel button (Escape key) during long-running queries - Show stream stats (record count, storage size) in delete confirmation dialog - Add comprehensive tests for error codes, user-friendly messages, stream name validation, SQL identifier escaping, and CSV export https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix baseURL returning valid URL for empty/hostless strings ServerConnection.baseURL would prepend "https://" to an empty string, producing "https://" which Foundation accepts as a valid URL. Now returns nil for empty input and validates that a host component is present. Fixes testClientInitInvalidURL CI failure. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix live tail bugs, health check silent failure, and URL encoding fallback - Fix auto-scroll in LiveTailView jumping to wrong entry when filter is active; now scrolls to last unfiltered entry when no filter, last filtered entry otherwise - Stop live tail when selected stream changes to prevent polling stale stream - Rebuild fingerprint set immediately when entries are trimmed (not deferred to 2x threshold) to prevent dedup misses for reappearing records - Make encodePathComponent throw on encoding failure instead of silently returning the unencoded string - Change checkHealth() from returning Bool to throwing on non-200, ensuring connect() properly rejects unhealthy servers instead of silently proceeding https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix ServerInfoView build error after checkHealth signature change checkHealth() now returns Void instead of Bool. Update ServerInfoView to determine health by catching the thrown error instead of using the return value. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Remove force-unwrap, fix docs, and add tests for round 4-5 features - Replace dict[key]! with safe compactMap in JSONValue.prettyPrinted() - Update CLAUDE.md to say Xcode 16+ (matches project.yml xcodeVersion) - Add prettyPrinted maxDepth truncation tests - Add baseURL nil tests for empty, whitespace-only, and scheme-only URLs - Add QueryViewModel.cancelQuery state transition tests - Add tests for escaped SQL identifiers in default queries - Add LiveTailViewModel start/stop lifecycle tests https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix timer leak, add Sendable conformances, improve thread safety and accessibility - Add deinit to LiveTailViewModel to invalidate timer if view is deallocated without calling stop(), preventing resource leaks - Add Sendable conformance to LiveTailEntry, SavedQuery, and QueryHistoryEntry for safe cross-concurrency-domain usage - Add @MainActor to AppState mutation methods (addConnection, updateConnection, addSavedQuery, removeSavedQuery, showErrorMessage) to prevent data races - Add accessibility labels to sort column headers, loading spinner, and SQL editor - Add tests for clear/fingerprint reset, stop/pause state, and log field summary https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix JSON string escaping, parallelize stream detail loading, add Sendable - Fix prettyPrinted() to properly escape quotes, backslashes, newlines, tabs, carriage returns, and control characters in JSON string values and object keys, producing valid JSON output in the Raw JSON view - Parallelize StreamDetailView.loadData() using async let so all 4 API calls (schema, stats, info, retention) run concurrently instead of sequentially, improving load times - Add Sendable conformance to ServerConnection (all fields are value types) - Add 7 tests covering JSON string escaping edge cases https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Add Sendable conformance to all model types, align Xcode version - Add Sendable to 14 model types returned from async ParseableClient methods: AlertConfig, AlertRule, AlertRuleSpec, AlertTarget, LogStream, StreamSchema, SchemaField, StreamStats, IngestionStats, StorageStats, StreamInfo, ServerAbout, StoreInfo, RetentionConfig, UserInfo, QueryResponse. All fields are value types so conformance is safe. - Update project.yml xcodeVersion from 16.0 to 16.2 to match CI workflow https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Use consistent user-friendly error messages across all views - Replace error.localizedDescription with ParseableError.userFriendlyMessage in AlertsView, UsersView, and LiveTailViewModel to match the pattern used in AppState and other views, providing clearer network error messages - Add missing accessibility label to ServerInfoView refresh button https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Use userFriendlyMessage for remaining error.localizedDescription calls - SidebarView: create/delete stream error messages now use ParseableError.userFriendlyMessage instead of raw localizedDescription - QueryViewModel: query execution error message now uses ParseableError.userFriendlyMessage for consistent user-facing messages All network error paths now use the same user-friendly formatting. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Throw errors on malformed API responses instead of returning empty data - query(): Throw decodingError when response matches neither QueryResponse nor [LogRecord] format, instead of silently returning [] - getAlerts(): Let decode errors propagate instead of silently returning empty AlertConfig, so users see an error if the API format changes - getRetention(): Throw decodingError when response matches neither array nor single-object format; add empty-data guard for streams with no retention configured - Add 4 tests verifying decode failure behavior for malformed responses https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Cancel orphaned queries on tab switch, add input validation, fix queue retention - QueryView: cancel running queries on .onDisappear to prevent orphaned tasks - ConnectionSheet: reject non-HTTP(S) URL schemes in validation - SidebarView: validateStreamName now rejects empty/whitespace-only names - AppState: retain network monitor dispatch queue as stored property - Add 6 tests for empty stream names, URL schemes, CSV edge cases https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix clipboard false-success, export silent failure, and orphaned connect task - LogDetailView: check NSPasteboard.setString return value before showing copy confirmation, preventing false success indication - QueryView: propagate JSON encoding errors to user via export error alert instead of silently writing empty "[]" to file - AppState: store auto-connect task and cancel on disconnect to prevent background network requests after user disconnects https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix CSV escaping for carriage returns and silence clipboard warnings - QueryViewModel.escapeCSV: add \r to the set of characters that trigger quoting, per RFC 4180. Without this, log messages with Windows-style line endings (\r\n) produce malformed CSV output. - LogTableView: explicitly discard NSPasteboard.setString return value to silence compiler warnings and acknowledge the result. - Add test for CSV carriage return escaping. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix stale keychain password and silent stream load failure on connect - ConnectionStore: delete keychain entry when password is cleared to prevent stale credentials from persisting after a user empties the password field (the Save button allows empty passwords) - AppState.connect: surface stream listing errors through streamLoadError instead of silently setting streams to []. This shows the retry button in the sidebar when stream loading fails after a successful health check. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix clipboard copy output and surface actual error in server info view - LogDetailView.copyToClipboard: remove no-op identity mapValues and add .sortedKeys/.prettyPrinted to the encoder so clipboard JSON has consistent key ordering (matching the export behavior in QueryView) - ServerInfoView.loadInfo: replace try?/generic message with proper error handling so the actual failure reason (auth, network, etc.) is shown instead of "Failed to load server information" https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix stale table data when consecutive queries return same row count onChange(of: records.count) only triggered a sort cache rebuild when the number of rows changed. If two different queries returned the same count, cachedSorted (an @State that persists across re-renders) kept stale data from the previous query. Change to onChange(of: records) so the rebuild triggers whenever the actual content changes. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix false truncation warning for custom SQL and cancel-on-disconnect alert - QueryViewModel: only show "Results may be truncated" when the auto-generated query was used (where we control the LIMIT). Previously, any custom SQL that happened to return exactly `maxQueryResults` rows would trigger the warning even though nothing was truncated. - AppState.connect: check Task.isCancelled in the catch block so that disconnecting during auto-reconnect doesn't show a spurious "Network error: cancelled" alert. disconnect() already cleans up state, so the catch block should be a no-op when the task was cancelled. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix silent request cancellation, CSV export data loss, and queryLimit bounds Three targeted fixes: - ParseableClient.deinit: switch from invalidateAndCancel() to finishTasksAndInvalidate(). The old behavior silently cancelled in-flight requests (e.g. a running query) when the client was deallocated, such as during a quick disconnect-reconnect cycle. Now pending requests complete gracefully. - CSV export: use new JSONValue.exportString instead of displayString for cell values. Previously, nested arrays/objects were exported as opaque placeholders ("[3 items]", "{2 fields}") — the CSV looked like it had data but the actual content was lost. Now they serialize as compact JSON. - queryLimit: clamp to 100,000 upper bound. The value comes from UserDefaults and is interpolated into SQL; a reasonable ceiling prevents issues from corrupted defaults. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix LiveTail fingerprint collisions and auto-stop on persistent errors Two LiveTail improvements: - Fingerprint deduplication: use exportString instead of displayString when hashing record values. displayString turns nested objects into opaque summaries like "{2 fields}", so two records that differ only in nested content (e.g. different host values inside a metadata object with the same field count) would produce identical fingerprints and the second record would be silently dropped. - Auto-stop on persistent failures: track consecutive poll errors and stop the live tail after 5 consecutive failures. Previously the timer kept firing every 2 seconds indefinitely when the server was down, generating unnecessary failed requests. The error message now tells the user why polling was stopped. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix stale stream selection on server switch and cache date formatter - AppState.connect: when switching to a different server, clear selectedStream and streamLoadError before connecting. Previously, switching from Server A (with "logs" selected) to Server B (which has no "logs" stream) left selectedStream pointing at a phantom stream — the detail pane still rendered tabs for it, and queries would fail against a nonexistent stream. - ParseableClient: cache ISO8601DateFormatter as a static property instead of allocating a new one per query() call. These formatters are expensive to initialize (they compile format patterns internally) and the query path is hot during live tail polling. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix numeric column sorting and stale query on stream switch Two interaction fixes: - LogTableView: sorting by numeric columns was lexicographic — values like 2, 10, 100 sorted as "10, 100, 2" because they were compared as displayString. Added Comparable conformance to JSONValue with type-aware ordering (ints/doubles compare numerically, strings use localizedStandardCompare, nulls sort first). The sort now works correctly for any column type. - QueryView: switching streams in the sidebar didn't update the query text. setDefaultQuery had `if sqlQuery.isEmpty` which was always false after the first stream was selected (the auto-generated query filled it in). Now it detects whether the current query is still the auto-generated default for the previous stream (by prefix matching) and replaces it. Custom user queries are preserved. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Clear stale data when switching streams in detail/alerts views StreamDetailView renders loading, error, and data sections sequentially (not exclusively), so when the user switches from stream A to stream B, the spinner shows but schema/stats/info/retention from stream A remain visible below — under a header that already says stream B. Clear all data at the start of loadData so only the spinner shows during loading. Apply the same fix to AlertsView (clear alertConfig). Its if/else-if rendering means stale data isn't visually shown during loading, but clearing it prevents any flash of old data during state transitions. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Add IMPROVEMENTS.md cataloging remaining potential improvements Comprehensive audit of every source file, organized by severity: - 4 high-priority items (keychain ACL, auth header degradation, data migration, auto-reconnect) - 11 medium-priority items (keyboard nav, accessibility, error exposure, backoff, loading states) - 12 low-priority items (UX polish, caching, minor edge cases) Also lists all fixes already applied during this audit for reference. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix build errors: deinit actor isolation and async Result init Two strict concurrency errors caught by CI: - LiveTailViewModel.deinit: accessing @MainActor-isolated `timer` from nonisolated deinit is not allowed. Mark the property as nonisolated(unsafe) since it's only mutated from @MainActor methods (start/stop) and deinit is a safety net. - StreamDetailView.loadData: Result.init(catching:) takes a synchronous closure, so `Result { try await ... }` doesn't compile under strict concurrency. Replace with direct async let calls and individual do/catch blocks — same concurrency, simpler code. https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Add README with features, build instructions, and architecture overview https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Update CLAUDE.md to reflect current codebase state - Add test command and update CI runner to macos-15 - Document concurrency model (Sendable, @MainActor) - Document Keychain password storage - Update key type descriptions (Comparable JSONValue, exportString, auto-stop on poll failures, stream-switch state clearing) - Add conventions for SQL escaping, stale data clearing, Sendable - Add "Common patterns" section: new API endpoints, new tabs, error handling https://claude.ai/code/session_01ARkcgccdjUvX66X3UWogYx * Fix two CI test failures: CSV CRLF escaping and RetentionConfig decoding - Fix escapeCSV to check Unicode scalars instead of Characters, so \r and \n inside \r\n (CRLF) grapheme clusters are correctly detected. Swift's String.contains treats CRLF as a single Character that doesn't match "\r" or "\n" individually. - Fix RetentionConfig decoder to throw on non-object JSON instead of silently returning an empty struct with all-nil fields. https://claude.ai/code/session_014JYYsebBBj4MimGa8BrVhp --------- Co-authored-by: Claude <noreply@anthropic.com>

commitb032d01c6072fa791dc61b74ffce6585d17f7592 authoriko <mail@iko.soy> dateSun 15 Feb 2026, 11:43 (8 months ago) parent4d2c2db
42 files changed+6519
.github/workflows/build.yml+158 CLAUDE.md+109 IMPROVEMENTS.md+111 ParseableViewer/App/AppState.swift+255 ParseableViewer/App/ParseableViewerApp.swift+49 ParseableViewer/Info.plist+32 ParseableViewer/Models/AlertModels.swift+54 ParseableViewer/Models/JSONValue.swift+182 ParseableViewer/Models/LogStream.swift+172 ParseableViewer/Models/ServerConnection.swift+54 ParseableViewer/Models/ServerInfo.swift+98 ParseableViewer/ParseableViewer.entitlements+10 ParseableViewer/Resources/Assets.xcassets/AccentColor.colorset/Contents.json+38 ParseableViewer/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json+58 ParseableViewer/Resources/Assets.xcassets/Contents.json+6 ParseableViewer/Services/ConnectionStore.swift+74 ParseableViewer/Services/KeychainService.swift+61 ParseableViewer/Services/ParseableClient.swift+280 ParseableViewer/ViewModels/LiveTailViewModel.swift+229 ParseableViewer/ViewModels/QueryViewModel.swift+330 ParseableViewer/Views/AlertsView.swift+144 ParseableViewer/Views/ConnectionSheet.swift+206 ParseableViewer/Views/ContentView.swift+180 ParseableViewer/Views/LiveTailView.swift+202 ParseableViewer/Views/LogDetailView.swift+201 ParseableViewer/Views/LogTableView.swift+200 ParseableViewer/Views/QueryView.swift+298 ParseableViewer/Views/ServerInfoView.swift+154 ParseableViewer/Views/SettingsView.swift+104 ParseableViewer/Views/SidebarView.swift+317 ParseableViewer/Views/StreamDetailView.swift+254 ParseableViewer/Views/TimeRangePicker.swift+90 ParseableViewer/Views/UsersView.swift+93 ParseableViewerTests/ConnectionStoreTests.swift+132 ParseableViewerTests/JSONValueTests.swift+270 ParseableViewerTests/LiveTailViewModelTests.swift+281 ParseableViewerTests/LogTableHelperTests.swift+63 ParseableViewerTests/ParseableClientTests.swift+466 ParseableViewerTests/QueryViewModelTests.swift+205 ParseableViewerTests/ServerConnectionTests.swift+80 README.md+145 project.yml+74
.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