LinksAnalysisView.swift
1 // 2 // LinksAnalysisView.swift 3 // RacerTracer 4 // 5 // Created by Alexander Kunau on 29.09.25. 6 // 7 8 import SwiftUI 9 import Charts 10 11 struct LinksAnalysisView: View { 12 @StateObject private var deepCrawlService = DeepCrawlService() 13 @State private var websiteURL = "" 14 @State private var selectedLinkType: LinkAnalysisType = .`internal` 15 @State private var isAnalyzing = false 16 17 var body: some View { 18 VStack(spacing: 24) { 19 // Header with Input 20 LinksInputSection( 21 websiteURL: $websiteURL, 22 onAnalyze: startLinksAnalysis, 23 isAnalyzing: isAnalyzing 24 ) 25 26 if isAnalyzing { 27 CrawlProgressView( 28 progress: deepCrawlService.crawlProgress, 29 crawledPages: deepCrawlService.crawledPages.count 30 ) 31 } else if !deepCrawlService.internalLinks.isEmpty || !deepCrawlService.externalLinks.isEmpty { 32 LinksResultsView( 33 crawlService: deepCrawlService, 34 selectedLinkType: $selectedLinkType 35 ) 36 } else { 37 LinksStartView() 38 } 39 } 40 .padding(24) 41 .navigationTitle("Links Analysis") 42 } 43 44 private func startLinksAnalysis() { 45 guard !websiteURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } 46 47 isAnalyzing = true 48 49 Task { 50 do { 51 try await deepCrawlService.startDeepCrawl(startURL: websiteURL) 52 await MainActor.run { 53 isAnalyzing = false 54 } 55 } catch { 56 await MainActor.run { 57 isAnalyzing = false 58 deepCrawlService.errorMessage = error.localizedDescription 59 } 60 } 61 } 62 } 63 } 64 65 // MARK: - Input Section 66 67 struct LinksInputSection: View { 68 @Binding var websiteURL: String 69 let onAnalyze: () -> Void 70 let isAnalyzing: Bool 71 72 var body: some View { 73 VStack(alignment: .leading, spacing: 16) { 74 Text("Deep Links Analysis") 75 .font(.title2) 76 .fontWeight(.semibold) 77 78 Text("Perform deep crawling to discover and analyze all internal and external links on a website") 79 .font(.subheadline) 80 .foregroundColor(.secondary) 81 82 HStack { 83 TextField("Website URL for deep links analysis", text: $websiteURL) 84 .textFieldStyle(RoundedBorderTextFieldStyle()) 85 .disabled(isAnalyzing) 86 87 Button(action: onAnalyze) { 88 HStack { 89 if isAnalyzing { 90 ProgressView() 91 .scaleEffect(0.8) 92 } else { 93 Image(systemName: "link") 94 } 95 Text(isAnalyzing ? "Crawling..." : "Start Analysis") 96 } 97 } 98 .buttonStyle(.borderedProminent) 99 .disabled(websiteURL.isEmpty || isAnalyzing) 100 } 101 } 102 .padding(20) 103 .background(Color(NSColor.controlBackgroundColor)) 104 .cornerRadius(12) 105 } 106 } 107 108 // MARK: - Progress View 109 110 struct CrawlProgressView: View { 111 let progress: Double 112 let crawledPages: Int 113 114 var body: some View { 115 VStack(spacing: 20) { 116 VStack(spacing: 12) { 117 ProgressView(value: progress) 118 .progressViewStyle(LinearProgressViewStyle()) 119 .frame(height: 8) 120 121 Text("Crawling Progress: \(Int(progress * 100))%") 122 .font(.headline) 123 .foregroundColor(.secondary) 124 } 125 126 HStack(spacing: 40) { 127 VStack(spacing: 4) { 128 Text("\(crawledPages)") 129 .font(.title2) 130 .fontWeight(.bold) 131 .foregroundColor(.blue) 132 133 Text("Pages Crawled") 134 .font(.caption) 135 .foregroundColor(.secondary) 136 } 137 138 VStack(spacing: 4) { 139 Text("Depth \(UserDefaults.standard.object(forKey: "CrawlingDepth") as? Int ?? 3)") 140 .font(.title2) 141 .fontWeight(.bold) 142 .foregroundColor(.green) 143 144 Text("Max Depth") 145 .font(.caption) 146 .foregroundColor(.secondary) 147 } 148 } 149 150 VStack(spacing: 8) { 151 Text("• Discovering internal links") 152 Text("• Analyzing external links") 153 Text("• Processing link relationships") 154 Text("• Generating link statistics") 155 } 156 .font(.subheadline) 157 .foregroundColor(.secondary) 158 } 159 .frame(maxWidth: .infinity) 160 .frame(height: 250) 161 .background(Color(NSColor.controlBackgroundColor)) 162 .cornerRadius(12) 163 } 164 } 165 166 // MARK: - Start View 167 168 struct LinksStartView: View { 169 var body: some View { 170 VStack(spacing: 20) { 171 Image(systemName: "link") 172 .font(.system(size: 48)) 173 .foregroundColor(.accentColor) 174 175 Text("Deep Links Analysis") 176 .font(.title2) 177 .fontWeight(.semibold) 178 179 Text("Discover and analyze all internal and external links across your website with configurable crawling depth and comprehensive link metrics.") 180 .font(.subheadline) 181 .foregroundColor(.secondary) 182 .multilineTextAlignment(.center) 183 } 184 .frame(maxWidth: .infinity, maxHeight: .infinity) 185 .background(Color(NSColor.controlBackgroundColor)) 186 .cornerRadius(12) 187 } 188 } 189 190 // MARK: - Results View 191 192 enum LinkAnalysisType: String, CaseIterable { 193 case `internal` = "Internal Links" 194 case external = "External Links" 195 case statistics = "Statistics" 196 case crawlData = "Crawl Data" 197 } 198 199 struct LinksResultsView: View { 200 @ObservedObject var crawlService: DeepCrawlService 201 @Binding var selectedLinkType: LinkAnalysisType 202 203 var body: some View { 204 VStack(spacing: 24) { 205 // Overview Cards 206 LinksOverviewCards(crawlService: crawlService) 207 208 // Link Type Selector 209 Picker("Link Type", selection: $selectedLinkType) { 210 ForEach(LinkAnalysisType.allCases, id: \.self) { type in 211 Text(type.rawValue).tag(type) 212 } 213 } 214 .pickerStyle(SegmentedPickerStyle()) 215 216 // Content based on selection 217 ScrollView { 218 switch selectedLinkType { 219 case .`internal`: 220 InternalLinksSection(links: crawlService.internalLinks) 221 case .external: 222 ExternalLinksSection(links: crawlService.externalLinks) 223 case .statistics: 224 LinksStatisticsSection( 225 statistics: crawlService.crawlStatistics, 226 internalLinks: crawlService.internalLinks, 227 externalLinks: crawlService.externalLinks 228 ) 229 case .crawlData: 230 CrawlDataSection(crawledPages: crawlService.crawledPages) 231 } 232 } 233 .background(Color(NSColor.controlBackgroundColor)) 234 .cornerRadius(12) 235 } 236 } 237 } 238 239 // MARK: - Overview Cards 240 241 struct LinksOverviewCards: View { 242 @ObservedObject var crawlService: DeepCrawlService 243 244 var body: some View { 245 HStack(spacing: 16) { 246 LinkOverviewCard( 247 title: "Pages Crawled", 248 value: "\(crawlService.crawledPages.count)", 249 subtitle: "total pages", 250 color: .blue 251 ) 252 253 LinkOverviewCard( 254 title: "Internal Links", 255 value: "\(crawlService.internalLinks.count)", 256 subtitle: "within domain", 257 color: .green 258 ) 259 260 LinkOverviewCard( 261 title: "External Links", 262 value: "\(crawlService.externalLinks.count)", 263 subtitle: "to other domains", 264 color: .orange 265 ) 266 267 LinkOverviewCard( 268 title: "Unique Domains", 269 value: "\(Set(crawlService.externalLinks.map(\.domain)).count)", 270 subtitle: "external domains", 271 color: .purple 272 ) 273 } 274 } 275 } 276 277 struct LinkOverviewCard: View { 278 let title: String 279 let value: String 280 let subtitle: String 281 let color: Color 282 283 var body: some View { 284 VStack(spacing: 8) { 285 Text(title) 286 .font(.caption) 287 .foregroundColor(.secondary) 288 289 Text(value) 290 .font(.title2) 291 .fontWeight(.bold) 292 .foregroundColor(color) 293 294 Text(subtitle) 295 .font(.caption2) 296 .foregroundColor(.secondary) 297 } 298 .frame(maxWidth: .infinity) 299 .padding(16) 300 .background(Color(NSColor.textBackgroundColor)) 301 .cornerRadius(8) 302 } 303 } 304 305 // MARK: - Internal Links Section 306 307 struct InternalLinksSection: View { 308 let links: [InternalLink] 309 @State private var searchText = "" 310 @State private var selectedDepth: Int? = nil 311 312 var body: some View { 313 VStack(alignment: .leading, spacing: 16) { 314 HStack { 315 Text("Internal Links (\(filteredLinks.count))") 316 .font(.headline) 317 .fontWeight(.semibold) 318 319 Spacer() 320 321 Menu("Filter by Depth") { 322 Button("All Depths") { selectedDepth = nil } 323 ForEach(1...10, id: \.self) { depth in 324 Button("Depth \(depth)") { selectedDepth = depth } 325 } 326 } 327 .menuStyle(BorderedButtonMenuStyle()) 328 } 329 330 TextField("Search internal links...", text: $searchText) 331 .textFieldStyle(RoundedBorderTextFieldStyle()) 332 333 LazyVStack(spacing: 8) { 334 ForEach(filteredLinks, id: \.id) { link in 335 LinksAnalysisInternalLinkRow(link: link) 336 } 337 } 338 .padding(.vertical) 339 } 340 .padding(20) 341 } 342 343 private var filteredLinks: [InternalLink] { 344 var filtered = links 345 346 if !searchText.isEmpty { 347 filtered = filtered.filter { 348 $0.targetURL.localizedCaseInsensitiveContains(searchText) || 349 $0.anchorText.localizedCaseInsensitiveContains(searchText) 350 } 351 } 352 353 if let depth = selectedDepth { 354 filtered = filtered.filter { $0.depth == depth } 355 } 356 357 return filtered 358 } 359 } 360 361 struct LinksAnalysisInternalLinkRow: View { 362 let link: InternalLink 363 364 var body: some View { 365 VStack(alignment: .leading, spacing: 8) { 366 HStack { 367 VStack(alignment: .leading, spacing: 4) { 368 Text(link.targetURL) 369 .font(.subheadline) 370 .fontWeight(.medium) 371 .lineLimit(1) 372 373 if !link.anchorText.isEmpty { 374 Text("Anchor: \(link.anchorText)") 375 .font(.caption) 376 .foregroundColor(.secondary) 377 .lineLimit(1) 378 } 379 380 Text("From: \(URL(string: link.sourceURL)?.path ?? link.sourceURL)") 381 .font(.caption) 382 .foregroundColor(.blue) 383 .lineLimit(1) 384 } 385 386 Spacer() 387 388 VStack(alignment: .trailing, spacing: 4) { 389 Text("Depth \(link.depth)") 390 .font(.caption) 391 .padding(.horizontal, 6) 392 .padding(.vertical, 2) 393 .background(Color.blue.opacity(0.2)) 394 .foregroundColor(.blue) 395 .cornerRadius(4) 396 397 Text(link.linkType.description) 398 .font(.caption2) 399 .foregroundColor(.secondary) 400 } 401 } 402 } 403 .padding(12) 404 .background(Color(NSColor.textBackgroundColor)) 405 .cornerRadius(8) 406 } 407 } 408 409 // MARK: - External Links Section 410 411 struct ExternalLinksSection: View { 412 let links: [ExternalLink] 413 @State private var searchText = "" 414 @State private var selectedDomain: String? = nil 415 416 var body: some View { 417 VStack(alignment: .leading, spacing: 16) { 418 HStack { 419 Text("External Links (\(filteredLinks.count))") 420 .font(.headline) 421 .fontWeight(.semibold) 422 423 Spacer() 424 425 Menu("Filter by Domain") { 426 Button("All Domains") { selectedDomain = nil } 427 ForEach(topDomains, id: \.self) { domain in 428 Button(domain) { selectedDomain = domain } 429 } 430 } 431 .menuStyle(BorderedButtonMenuStyle()) 432 } 433 434 TextField("Search external links...", text: $searchText) 435 .textFieldStyle(RoundedBorderTextFieldStyle()) 436 437 // Domain Statistics 438 DomainStatisticsChart(links: filteredLinks) 439 440 LazyVStack(spacing: 8) { 441 ForEach(filteredLinks, id: \.id) { link in 442 LinksAnalysisExternalLinkRow(link: link) 443 } 444 } 445 .padding(.vertical) 446 } 447 .padding(20) 448 } 449 450 private var filteredLinks: [ExternalLink] { 451 var filtered = links 452 453 if !searchText.isEmpty { 454 filtered = filtered.filter { 455 $0.targetURL.localizedCaseInsensitiveContains(searchText) || 456 $0.anchorText.localizedCaseInsensitiveContains(searchText) || 457 $0.domain.localizedCaseInsensitiveContains(searchText) 458 } 459 } 460 461 if let domain = selectedDomain { 462 filtered = filtered.filter { $0.domain == domain } 463 } 464 465 return filtered 466 } 467 468 private var topDomains: [String] { 469 let domainCounts = Dictionary(grouping: links, by: \.domain) 470 .mapValues { $0.count } 471 472 return domainCounts.sorted { $0.value > $1.value } 473 .prefix(10) 474 .map(\.key) 475 } 476 } 477 478 struct LinksAnalysisExternalLinkRow: View { 479 let link: ExternalLink 480 481 var body: some View { 482 VStack(alignment: .leading, spacing: 8) { 483 HStack { 484 VStack(alignment: .leading, spacing: 4) { 485 Text(link.targetURL) 486 .font(.subheadline) 487 .fontWeight(.medium) 488 .lineLimit(1) 489 490 if !link.anchorText.isEmpty { 491 Text("Anchor: \(link.anchorText)") 492 .font(.caption) 493 .foregroundColor(.secondary) 494 .lineLimit(1) 495 } 496 497 Text("From: \(URL(string: link.sourceURL)?.path ?? link.sourceURL)") 498 .font(.caption) 499 .foregroundColor(.blue) 500 .lineLimit(1) 501 } 502 503 Spacer() 504 505 VStack(alignment: .trailing, spacing: 4) { 506 Text(link.domain) 507 .font(.caption) 508 .padding(.horizontal, 6) 509 .padding(.vertical, 2) 510 .background(Color.orange.opacity(0.2)) 511 .foregroundColor(.orange) 512 .cornerRadius(4) 513 514 Text(link.linkType.description) 515 .font(.caption2) 516 .foregroundColor(.secondary) 517 } 518 } 519 } 520 .padding(12) 521 .background(Color(NSColor.textBackgroundColor)) 522 .cornerRadius(8) 523 } 524 } 525 526 // MARK: - Domain Statistics Chart 527 528 struct DomainStatisticsChart: View { 529 let links: [ExternalLink] 530 531 var body: some View { 532 VStack(alignment: .leading, spacing: 12) { 533 Text("Top External Domains") 534 .font(.subheadline) 535 .fontWeight(.medium) 536 537 if !domainData.isEmpty { 538 Chart(domainData, id: \.domain) { data in 539 BarMark( 540 x: .value("Count", data.count), 541 y: .value("Domain", data.domain) 542 ) 543 .foregroundStyle(Color.orange.gradient) 544 } 545 .frame(height: 200) 546 } else { 547 Text("No external links found") 548 .font(.caption) 549 .foregroundColor(.secondary) 550 } 551 } 552 } 553 554 private var domainData: [DomainCount] { 555 let domainCounts = Dictionary(grouping: links, by: \.domain) 556 .mapValues { $0.count } 557 558 return domainCounts.sorted { $0.value > $1.value } 559 .prefix(10) 560 .map { DomainCount(domain: $0.key, count: $0.value) } 561 } 562 } 563 564 struct DomainCount { 565 let domain: String 566 let count: Int 567 } 568 569 // MARK: - Statistics Section 570 571 struct LinksStatisticsSection: View { 572 let statistics: CrawlStatistics? 573 let internalLinks: [InternalLink] 574 let externalLinks: [ExternalLink] 575 576 var body: some View { 577 VStack(alignment: .leading, spacing: 20) { 578 Text("Crawl Statistics") 579 .font(.headline) 580 .fontWeight(.semibold) 581 582 if let stats = statistics { 583 LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) { 584 StatisticCard(title: "Total Pages", value: "\(stats.totalPagesCrawled)", color: .blue) 585 StatisticCard(title: "Total Links", value: "\(stats.totalLinksFound)", color: .green) 586 StatisticCard(title: "Internal Links", value: "\(stats.internalLinksCount)", color: .orange) 587 StatisticCard(title: "External Links", value: "\(stats.externalLinksCount)", color: .purple) 588 StatisticCard(title: "Crawl Duration", value: String(format: "%.1fs", stats.crawlDuration), color: .red) 589 StatisticCard(title: "Avg Page Size", value: formatBytes(stats.averagePageSize), color: .cyan) 590 StatisticCard(title: "Errors", value: "\(stats.errorCount)", color: .red) 591 StatisticCard(title: "Max Depth", value: "\(stats.deepestLevelReached)", color: .indigo) 592 } 593 } 594 595 // Link Distribution Chart 596 LinkDistributionChart(internalLinks: internalLinks, externalLinks: externalLinks) 597 } 598 .padding(20) 599 } 600 601 private func formatBytes(_ bytes: Double) -> String { 602 let kb = bytes / 1024 603 if kb < 1024 { 604 return String(format: "%.1f KB", kb) 605 } else { 606 return String(format: "%.1f MB", kb / 1024) 607 } 608 } 609 } 610 611 struct StatisticCard: View { 612 let title: String 613 let value: String 614 let color: Color 615 616 var body: some View { 617 VStack(spacing: 8) { 618 Text(title) 619 .font(.caption) 620 .foregroundColor(.secondary) 621 622 Text(value) 623 .font(.title3) 624 .fontWeight(.bold) 625 .foregroundColor(color) 626 } 627 .frame(maxWidth: .infinity) 628 .padding(16) 629 .background(Color(NSColor.textBackgroundColor)) 630 .cornerRadius(8) 631 } 632 } 633 634 struct LinkDistributionChart: View { 635 let internalLinks: [InternalLink] 636 let externalLinks: [ExternalLink] 637 638 var body: some View { 639 VStack(alignment: .leading, spacing: 12) { 640 Text("Link Distribution by Depth") 641 .font(.subheadline) 642 .fontWeight(.medium) 643 644 Chart(linksByDepth, id: \.depth) { data in 645 BarMark( 646 x: .value("Depth", "Level \(data.depth)"), 647 y: .value("Count", data.count) 648 ) 649 .foregroundStyle(Color.blue.gradient) 650 } 651 .frame(height: 150) 652 } 653 } 654 655 private var linksByDepth: [DepthCount] { 656 let internalByDepth = Dictionary(grouping: internalLinks, by: \.depth) 657 .mapValues { $0.count } 658 659 let maxDepth = internalByDepth.keys.max() ?? 1 660 661 return (1...maxDepth).map { depth in 662 DepthCount(depth: depth, count: internalByDepth[depth] ?? 0) 663 } 664 } 665 } 666 667 struct DepthCount { 668 let depth: Int 669 let count: Int 670 } 671 672 // MARK: - Crawl Data Section 673 674 struct CrawlDataSection: View { 675 let crawledPages: [CrawledPage] 676 @State private var searchText = "" 677 678 var body: some View { 679 VStack(alignment: .leading, spacing: 16) { 680 Text("Crawled Pages (\(filteredPages.count))") 681 .font(.headline) 682 .fontWeight(.semibold) 683 684 TextField("Search crawled pages...", text: $searchText) 685 .textFieldStyle(RoundedBorderTextFieldStyle()) 686 687 LazyVStack(spacing: 8) { 688 ForEach(filteredPages, id: \.id) { page in 689 CrawledPageRow(page: page) 690 } 691 } 692 } 693 .padding(20) 694 } 695 696 private var filteredPages: [CrawledPage] { 697 if searchText.isEmpty { 698 return crawledPages 699 } else { 700 return crawledPages.filter { 701 $0.url.localizedCaseInsensitiveContains(searchText) || 702 ($0.title?.localizedCaseInsensitiveContains(searchText) ?? false) 703 } 704 } 705 } 706 } 707 708 struct CrawledPageRow: View { 709 let page: CrawledPage 710 711 var body: some View { 712 VStack(alignment: .leading, spacing: 8) { 713 HStack { 714 VStack(alignment: .leading, spacing: 4) { 715 Text(page.title ?? "No Title") 716 .font(.subheadline) 717 .fontWeight(.medium) 718 .lineLimit(1) 719 720 Text(page.url) 721 .font(.caption) 722 .foregroundColor(.blue) 723 .lineLimit(1) 724 725 if let error = page.crawlError { 726 Text("Error: \(error)") 727 .font(.caption) 728 .foregroundColor(.red) 729 .lineLimit(1) 730 } 731 } 732 733 Spacer() 734 735 VStack(alignment: .trailing, spacing: 4) { 736 Text("Depth \(page.depth)") 737 .font(.caption) 738 .padding(.horizontal, 6) 739 .padding(.vertical, 2) 740 .background(Color.blue.opacity(0.2)) 741 .foregroundColor(.blue) 742 .cornerRadius(4) 743 744 if let size = page.contentSize { 745 Text(formatBytes(Double(size))) 746 .font(.caption2) 747 .foregroundColor(.secondary) 748 } 749 750 Text(page.crawlDate, style: .time) 751 .font(.caption2) 752 .foregroundColor(.secondary) 753 } 754 } 755 } 756 .padding(12) 757 .background(Color(NSColor.textBackgroundColor)) 758 .cornerRadius(8) 759 .overlay( 760 RoundedRectangle(cornerRadius: 8) 761 .strokeBorder(page.crawlError != nil ? Color.red.opacity(0.3) : Color.clear, lineWidth: 1) 762 ) 763 } 764 765 private func formatBytes(_ bytes: Double) -> String { 766 let kb = bytes / 1024 767 if kb < 1024 { 768 return String(format: "%.1f KB", kb) 769 } else { 770 return String(format: "%.1f MB", kb / 1024) 771 } 772 } 773 } 774 775 #Preview { 776 LinksAnalysisView() 777 }