Files

298 lines
11 KiB
Swift

import Foundation
import Observation
/// ServerModel is the state of one walker server: its scrapes and a page of results.
///
/// It is the connection boundary of the app - a local walker, run by
/// LocalWalkers with the bundled helper, is just another server on localhost.
@MainActor
@Observable
final class ServerModel {
/// shapeDevModel is the shape developer of this server - made when its
/// section opens the first time, kept while the app runs
@ObservationIgnored private var shapeDevModel: ShapeDevModel?
var shapeDev: ShapeDevModel {
if let shapeDevModel {
return shapeDevModel
}
let model = ShapeDevModel(server: server)
shapeDevModel = model
return model
}
enum Connection: Equatable {
case unknown
case online
case offline(String)
}
/// Scrape mirrors the scrape label of the walker metrics and Filters.scrape.
enum Scrape: String, CaseIterable, Identifiable {
case current
case complete
var id: Self { self }
var title: String {
switch self {
case .current: "Current"
case .complete: "Last complete"
}
}
}
/// DurationRange is a performance bucket as a filter - [from, to) in nanoseconds
struct DurationRange: Hashable {
let from: Int64
let to: Int64
init(_ bucket: WalkerVO.PerformanceBucket) {
from = bucket.from.rawValue
to = bucket.to.rawValue
}
}
struct ResultsQuery: Hashable {
var scrape: Scrape
var prefix: String
var search: String
var status: Int?
var group: String?
var duration: DurationRange?
var page: Int
}
let server: Server
@ObservationIgnored private let client: Walker.ServiceClient
/// liveMetrics are the prometheus metrics of the walker - ServerStore.sampleMetrics samples them in the background
let liveMetrics: LiveMetrics
private(set) var connection: Connection = .unknown
private(set) var current: WalkerVO.ScrapeSummary?
private(set) var complete: WalkerVO.ScrapeSummary?
// results query - changing a filter starts over at the first page
var scrape: Scrape = .current {
didSet { if scrape != oldValue { page = 0 } }
}
var prefix = "" {
didSet { if prefix != oldValue { page = 0 } }
}
/// search filters by the path of the results - see Filters.path
var search = "" {
didSet { if search != oldValue { page = 0 } }
}
var status: Int? {
didSet { if status != oldValue { page = 0 } }
}
/// group filters by group - "" are the results without a group
var group: String? {
didSet { if group != oldValue { page = 0 } }
}
/// duration filters by a performance bucket
var duration: DurationRange? {
didSet { if duration != oldValue { page = 0 } }
}
var page = 0
let pageSize = 100
private(set) var results: [WalkerVO.ScrapeResult] = []
/// resultsPrefix is the prefix the results were loaded with - render them
/// relative to it, not to the prefix being edited
private(set) var resultsPrefix = ""
/// folderPage is the page of the folder resultsPrefix itself, e.g. the
/// result for https://example.com/shop or .../shop/ - it sorts first, so it is taken
/// from the first page and kept while paging
private(set) var folderPage: WalkerVO.ScrapeResult?
private(set) var numPages = 0
private(set) var statusOptions: [Walker.StatusStats] = []
private(set) var groupOptions: [Walker.GroupStats] = []
private(set) var bucketOptions: [WalkerVO.PerformanceBucket] = []
private(set) var resultsError: String?
private(set) var isLoadingResults = false
/// folders is the path tree of all results of the selected scrape - it
/// comes with every page of results, so both always correspond
private(set) var folders: [WalkerVO.Folder] = []
var resultsQuery: ResultsQuery {
ResultsQuery(scrape: scrape, prefix: prefix, search: search, status: status, group: group, duration: duration, page: page)
}
init(server: Server) {
self.server = server
client = Walker.ServiceClient(transport: URLSessionTransport(baseURL: server.baseURL))
liveMetrics = LiveMetrics(baseURL: server.baseURL)
}
func refreshScrapes() async {
do {
let scrapes = try await client.getScrapes()
current = scrapes.current
complete = scrapes.complete
connection = .online
} catch is CancellationError {
return
} catch {
connection = .offline(error.localizedDescription)
}
}
/// restartScrape starts a new scrape cycle on the server right away - a
/// running one is aborted. The error is shown in the overview.
private(set) var restartError: String?
func restartScrape() async {
do {
restartError = try await client.restartScrape() ? nil : "The server has no walk to restart."
} catch is CancellationError {
return
} catch {
restartError = error.localizedDescription
}
await refreshScrapes()
}
/// pollScrapes refreshes until the calling task is cancelled.
func pollScrapes(every interval: Swift.Duration) async {
while !Task.isCancelled {
await refreshScrapes()
try? await Task.sleep(for: interval)
}
}
/// brokenLinks of the selected scrape - see loadBrokenLinks
private(set) var brokenLinks: [WalkerVO.BrokenLink] = []
private(set) var brokenLinksError: String?
private(set) var isLoadingBrokenLinks = false
func loadBrokenLinks() async {
isLoadingBrokenLinks = true
defer { isLoadingBrokenLinks = false }
do {
let brokenLinks = try await client.getBrokenLinks(scrape: scrape.rawValue) ?? []
guard !Task.isCancelled else { return }
self.brokenLinks = brokenLinks
brokenLinksError = nil
connection = .online
} catch is CancellationError {
return
} catch {
brokenLinksError = error.localizedDescription
connection = .offline(error.localizedDescription)
}
}
/// redirects of the selected scrape - see loadRedirects
private(set) var redirects: [WalkerVO.RedirectChain] = []
private(set) var redirectsError: String?
private(set) var isLoadingRedirects = false
func loadRedirects() async {
isLoadingRedirects = true
defer { isLoadingRedirects = false }
do {
let redirects = try await client.getRedirects(scrape: scrape.rawValue) ?? []
guard !Task.isCancelled else { return }
self.redirects = redirects
redirectsError = nil
connection = .online
} catch is CancellationError {
return
} catch {
redirectsError = error.localizedDescription
connection = .offline(error.localizedDescription)
}
}
/// links are the link counts of every page of the selected scrape
private(set) var links: [WalkerVO.PageLinks] = []
private(set) var linksError: String?
private(set) var isLoadingLinks = false
func loadLinks() async {
isLoadingLinks = true
defer { isLoadingLinks = false }
do {
let links = try await client.getLinks(scrape: scrape.rawValue) ?? []
guard !Task.isCancelled else { return }
self.links = links
linksError = nil
connection = .online
} catch is CancellationError {
return
} catch {
linksError = error.localizedDescription
connection = .offline(error.localizedDescription)
}
}
/// linkedFrom returns the pages of the selected scrape linking to a url -
/// totals and groups of all, but at most limit pages
func linkedFrom(_ url: String, limit: Int) async throws -> WalkerVO.LinkedFromPage {
try await client.getLinkedFrom(scrape: scrape.rawValue, targetURL: url, limit: limit)
}
/// isAvailable - the last complete scrape only exists, once one completed;
/// the current one is always selectable, while walker idles it is empty
func isAvailable(_ scrape: Scrape) -> Bool {
switch scrape {
case .current: true
case .complete: complete != nil
}
}
/// lookupResult finds the result of the selected scrape for a url, as the
/// preview browses it - nil, if walker did not scrape it
func lookupResult(_ url: String) async throws -> WalkerVO.ScrapeResult? {
try await client.getResult(scrape: scrape.rawValue, targetURL: url)
}
/// semanticDocument has the server fetch a page, like the crawler does, and
/// extract its semantic outline - with content: texts, links and images
func semanticDocument(_ url: String, content: Bool) async throws -> WalkerVO.SemanticDocument {
try await client.getSemanticDocument(targetURL: url, html: "", content: content)
}
func loadResults() async {
isLoadingResults = true
defer { isLoadingResults = false }
let filters = Walker.Filters(
scrape: scrape.rawValue,
prefix: prefix,
path: search,
status: status.map { [$0] },
groups: group.map { [$0] },
minDur: GoTime.Duration(rawValue: duration?.from ?? 0),
maxDur: GoTime.Duration(rawValue: duration?.to ?? 0)
)
do {
let reply = try await client.getResults(filters: filters, page: page, pageSize: pageSize)
// a newer query took over while this one was loading
guard !Task.isCancelled else { return }
results = reply.results ?? []
folders = reply.folders ?? []
if page == 0 || filters.prefix != resultsPrefix {
folderPage = results.first { $0.targetURL == filters.prefix || $0.targetURL == filters.prefix + "/" }
}
resultsPrefix = filters.prefix
numPages = reply.numPages
statusOptions = (reply.filterOptions.status ?? []).sorted { $0.code < $1.code }
groupOptions = reply.filterOptions.groups ?? []
bucketOptions = reply.filterOptions.buckets ?? []
resultsError = nil
connection = .online
} catch is CancellationError {
return
} catch {
resultsError = error.localizedDescription
connection = .offline(error.localizedDescription)
}
}
}
extension WalkerVO.ScrapeResult: Identifiable {
public var id: String { targetURL }
}