From 96656438c6bba248f2aaa27a418bc95f3a5c9d40 Mon Sep 17 00:00:00 2001 From: relikd Date: Wed, 24 Jun 2020 13:09:11 +0200 Subject: [PATCH] Context analysis: Co-Occurrence --- AppCheck.xcodeproj/project.pbxproj | 16 ++ main/Assets.xcassets/.DS_Store | Bin 0 -> 10244 bytes .../intersection.imageset/Contents.json | 23 ++ .../intersection.imageset/img.png | Bin 0 -> 385 bytes .../intersection.imageset/img@2x.png | Bin 0 -> 695 bytes .../intersection.imageset/img@3x.png | Bin 0 -> 1028 bytes main/Base.lproj/Main.storyboard | 205 +++++++++++++++++- main/Common Classes/IBViews.swift | 83 +++++++ main/DB/DBAppOnly.swift | 75 ++++++- main/DB/DBCore.swift | 1 + main/Extensions/SharedState.swift | 6 + main/Extensions/TableView.swift | 8 + main/Requests/Analytics/VCCoOccurrence.swift | 88 ++++++++ main/Requests/TVCHostDetails.swift | 30 ++- main/Requests/VCDateFilter.swift | 31 --- 15 files changed, 526 insertions(+), 40 deletions(-) create mode 100644 main/Assets.xcassets/.DS_Store create mode 100644 main/Assets.xcassets/intersection.imageset/Contents.json create mode 100644 main/Assets.xcassets/intersection.imageset/img.png create mode 100644 main/Assets.xcassets/intersection.imageset/img@2x.png create mode 100644 main/Assets.xcassets/intersection.imageset/img@3x.png create mode 100644 main/Common Classes/IBViews.swift create mode 100644 main/Requests/Analytics/VCCoOccurrence.swift diff --git a/AppCheck.xcodeproj/project.pbxproj b/AppCheck.xcodeproj/project.pbxproj index ff58aeb..186c4ab 100644 --- a/AppCheck.xcodeproj/project.pbxproj +++ b/AppCheck.xcodeproj/project.pbxproj @@ -16,6 +16,8 @@ 541AC5DD2399498A00A769D7 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5DB2399498A00A769D7 /* Main.storyboard */; }; 541AC5DF2399498B00A769D7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5DE2399498B00A769D7 /* Assets.xcassets */; }; 541AC5E22399498B00A769D7 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5E02399498B00A769D7 /* LaunchScreen.storyboard */; }; + 541FC47624A12D01009154D8 /* IBViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541FC47524A12D01009154D8 /* IBViews.swift */; }; + 541FC47824A1453F009154D8 /* VCCoOccurrence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541FC47724A1453F009154D8 /* VCCoOccurrence.swift */; }; 541FC9872497D81C00962623 /* TheGreatDestroyer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541FC9862497D81C00962623 /* TheGreatDestroyer.swift */; }; 542E2A982404973F001462DC /* TBCMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542E2A972404973F001462DC /* TBCMain.swift */; }; 542E2A9A24051556001462DC /* TVCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542E2A9924051556001462DC /* TVCSettings.swift */; }; @@ -176,6 +178,8 @@ 541AC5DE2399498B00A769D7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 541AC5E12399498B00A769D7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 541AC5E32399498B00A769D7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 541FC47524A12D01009154D8 /* IBViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IBViews.swift; sourceTree = ""; }; + 541FC47724A1453F009154D8 /* VCCoOccurrence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCCoOccurrence.swift; sourceTree = ""; }; 541FC9862497D81C00962623 /* TheGreatDestroyer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TheGreatDestroyer.swift; sourceTree = ""; }; 542E2A972404973F001462DC /* TBCMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBCMain.swift; sourceTree = ""; }; 542E2A9924051556001462DC /* TVCSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCSettings.swift; sourceTree = ""; }; @@ -328,6 +332,7 @@ 5412F8ED24571B8100A63D7A /* VCDateFilter.swift */, 54953E6023E0D69A0054345C /* TVCHosts.swift */, 54953E6E23E44CD00054345C /* TVCHostDetails.swift */, + 541FC47424A12CE9009154D8 /* Analytics */, ); path = Requests; sourceTree = ""; @@ -394,6 +399,14 @@ path = main; sourceTree = ""; }; + 541FC47424A12CE9009154D8 /* Analytics */ = { + isa = PBXGroup; + children = ( + 541FC47724A1453F009154D8 /* VCCoOccurrence.swift */, + ); + path = Analytics; + sourceTree = ""; + }; 542E2A9B24051F79001462DC /* media */ = { isa = PBXGroup; children = ( @@ -425,6 +438,7 @@ 54D8B979246C9F2000EB2414 /* FilterPipeline.swift */, 54448A3124899A4000771C96 /* SearchBarManager.swift */, 54EFA4E5248EEE240022D618 /* DatePickerAlert.swift */, + 541FC47524A12D01009154D8 /* IBViews.swift */, ); path = "Common Classes"; sourceTree = ""; @@ -830,6 +844,7 @@ 540E67842433FAFE00871BBE /* TVCPreviousRecords.swift in Sources */, 54B345A6241BB982004C53CC /* Notifications.swift in Sources */, 54448A2E2486464F00771C96 /* Array.swift in Sources */, + 541FC47824A1453F009154D8 /* VCCoOccurrence.swift in Sources */, 54B345AB241BBA5B004C53CC /* AlertSheet.swift in Sources */, 544C95262407B1C700AB89D0 /* SharedState.swift in Sources */, 545DDDCF243E6267003B6544 /* TutorialSheet.swift in Sources */, @@ -855,6 +870,7 @@ 5412F8EE24571B8200A63D7A /* VCDateFilter.swift in Sources */, 545DDDD124436983003B6544 /* QuickUI.swift in Sources */, 541AC5D82399498A00A769D7 /* AppDelegate.swift in Sources */, + 541FC47624A12D01009154D8 /* IBViews.swift in Sources */, 540E67822433483D00871BBE /* VCEditRecording.swift in Sources */, 54EFA4E82491A16A0022D618 /* Font.swift in Sources */, 54D8B97A246C9F2000EB2414 /* FilterPipeline.swift in Sources */, diff --git a/main/Assets.xcassets/.DS_Store b/main/Assets.xcassets/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a60f8eabc4317410a20c8ed1e03f3f64be89b43e GIT binary patch literal 10244 zcmZQzU|@7AO)+F(P+(wS;9!8z0z3>@0Z1N%F(jFwA|Odd1_l8JhCGHuh8zYxhD?TB zsN5(u8UmvsFd71*Aut*OqaiT3LVywC91d;>JxY#-z-S1Jh5$SSK;;9dg8-r(Kz#-V z28IR*4H9BtWMBYy0T>w=SYVnU{Qw4#97rpO25AM+Agv6HAQspRuvP{}s8&XBHw2_l z0BjT^xN8F9gS9g-f^BACUT7q8)7JC@~rW zqalDA0-$Uoz>vj|&rr%xgwlUcWJqU7WhiDyWhhB1FD^*R$xmWnU^tOfkds+lVqkEc zk%^gwm5rT)lY^6kmm@YfBfmVjB(bEl*eS6n8pI1oEXhcMvP1IobKva6q_E7?@^}Fe z=lr~q#LT?ZB9QXn%#_rm#G;t+%)FHRa;N;#yp&?FIZz3ZJ)H3Z;?>ormXJ(R5GXB94NA>P zEXgcObxABqEsn@c&d&oW6JX@zSj{UYt)QxUWGO;*0H7_MIFFh(VB{i=kGc7ZO? zr1FZO06(7$w;WeYN@7W>Z+;3$R|;oH#;w5> zQk+?p${`R2QL4?Q%N3HHnU}&TPz(_<;45MZOXE82j zT*A1NaXsS>#=VUDAZ}xV)@=;l5I&4#JWga{g=SKwB&>)!a(T92^4s1vtQ5E^c*BP7Z-aFo&C4TZe-~V5 literal 0 HcmV?d00001 diff --git a/main/Assets.xcassets/intersection.imageset/Contents.json b/main/Assets.xcassets/intersection.imageset/Contents.json new file mode 100644 index 0000000..d9287fd --- /dev/null +++ b/main/Assets.xcassets/intersection.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "img.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "img@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "img@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/main/Assets.xcassets/intersection.imageset/img.png b/main/Assets.xcassets/intersection.imageset/img.png new file mode 100644 index 0000000000000000000000000000000000000000..4296b2448ef240cba84ed0d47eb61e87cda68ce7 GIT binary patch literal 385 zcmeAS@N?(olHy`uVBq!ia0y~yV2}i14rT@hhR^1ufeZ``#sNMdt_-Au#jI{885kG@ zOM?7@859cU@9$3#`2W7az~TJ*`vI34gf}rTFud_}aSW+oTzb&E=zszb>p@1&k1R&t z{`$`@s_V|Ve71gT-aBQrdXvl3VLFQY<1B+dZIhTCRiTso_mwQ%k5;N?%`>syLztAl@S$q#k2YHj>*>rE17jd zcenK(4Pew+$SlCs_BGk&6@%Iq2Crs`WR|6@1>BbFeWJc68jBpbrkEeM%r(;fa!7-= zfvAJ`+sJF_Y8Rdc?5b$}wD8%brlmOtCVu2PIX__Aj;YcKaaO_!@xRh0`+pAVYZ6;o p^8M;j+*5ykzrB;6m$rb{vw7EF$$%Y#`}6&O8&2e|cYgf2;^C_O0!``+n*T1GE_%Fa zp7B}3j_$SPzux4l?hd{gx+p{Svi`gNch*@y9|%0M{O$iO^B%@J zcrx+$IiHn1V!KQtTtz)^8eZ(3ZpOW4%U-^#l4?9N8p=fF_srO+Cv@t9%c+ph&7@*aU{!dwrZICeZS-FJ~;_SY?%-Cr(d z-3;T(KHTjTJelv2#Vx^GUYc3S_r5pxBp!O{RSL?h#^XOl}jO;C0tj?323i71Z^z*G}+1J{-K!N$~|NhfLf)gt_ z|5lW)6ul>w@9vjUe=O`K$G+vyT#vWNfBF35c>DZ>`kz_Vb0$Q*trn_wVm#PfHiaYH z=x$6=^Wj^2Z%ygZ{` zt7O61C(BM;393HgoAYrh&#RCP6`%Z)7sMLzX`Ge~k+yw%UiIcSYZKpNr8+aN{W-Oi zXa2)(^E=zNpE%`o^;+1nT|eS#uWiXFli0Or)i)^)=Y?NN9P>p^&9M3(KYPZOB{A`r zd`efQ)~E^SnLm1$nW?^d^Bm@8^-wA1#k01WN3|xnmictap5JxLT6z=5lxZfj*DY0% zzgpzWb|P3e=Q+!pu#H-`j^0#baxggdtwZII7u)JR1-Gsr_-!F=cW&xN;fWrbU;nS` zTej+y{QHRlyp`YVZmL}JFxKt-arss0ciwdQSmO)3T~DmZQnRpY63zW`_>&_~!(0xY zXUYg_HjBH2qZUD+Gqf%8@J5<-@m)G z;ll49%BHtX_61(j-yG|`RGRr%_@^yVTaWvBn!hbwWfsEhQ_y?Fymfm_q#yIPV-74m z^>urSK4v`77584rRX53p^T!#Hn}n-WyNl}c}6|dJ@S^fg7?f$)~r0ZZ<$9CR@$t~=UGvuq>coK>sgu!xsgvVRUO23hI7Oe)H}%p-x3rH( z5`Qkqs_%G`EBod2uihSi^~#siBlsj=ug|YbH%U4g&T`U!vgx&DPhQ3EpJ+03-iLp4 zOI(cBzX-Ln33I6UB(nNZ{p6nof1{=p?U>nb + + + + + + + + + + + - + @@ -424,11 +435,202 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1031,6 +1233,7 @@ Duration: 60:00 + diff --git a/main/Common Classes/IBViews.swift b/main/Common Classes/IBViews.swift new file mode 100644 index 0000000..0fe7789 --- /dev/null +++ b/main/Common Classes/IBViews.swift @@ -0,0 +1,83 @@ +import UIKit +import CoreGraphics + +// MARK: White Triangle Popup Arrow + +@IBDesignable +class PopupTriangle: UIView { + @IBInspectable var rotation: CGFloat = 0 + @IBInspectable var color: UIColor = .black + + override func draw(_ rect: CGRect) { + guard let c = UIGraphicsGetCurrentContext() else { return } + let w = rect.width, h = rect.height + switch rotation { + case 90: // right + c.lineFromTo(x1: 0, y1: 0, x2: w, y2: h/2) + c.addLine(to: CGPoint(x: 0, y: h)) + case 180: // bottom + c.lineFromTo(x1: w, y1: 0, x2: w/2, y2: h) + c.addLine(to: CGPoint(x: 0, y: 0)) + case 270: // left + c.lineFromTo(x1: w, y1: h, x2: 0, y2: h/2) + c.addLine(to: CGPoint(x: w, y: 0)) + default: // top + c.lineFromTo(x1: 0, y1: h, x2: w/2, y2: 0) + c.addLine(to: CGPoint(x: w, y: h)) + } + c.closePath() + c.setFillColor(color.cgColor) + c.fillPath() + } +} + + +// MARK: Label as Tag Bubble + +@IBDesignable +class TagLabel: UILabel { + private var em: CGFloat { font.pointSize } + @IBInspectable var padTop: CGFloat = 0 + @IBInspectable var padLeft: CGFloat = 0 + @IBInspectable var padRight: CGFloat = 0 + @IBInspectable var padBottom: CGFloat = 0 + private var padding: UIEdgeInsets { + .init(top: padTop + em/6, left: padLeft + em/3, + bottom: padBottom + em/6, right: padRight + em/3) + } + + override func textRect(forBounds bounds: CGRect, limitedToNumberOfLines numberOfLines: Int) -> CGRect { + let i = padding + let ii = UIEdgeInsets(top: -i.top, left: -i.left, bottom: -i.bottom, right: -i.right) + return super.textRect(forBounds: bounds.inset(by: i), + limitedToNumberOfLines: numberOfLines).inset(by: ii) + } + + override func drawText(in rect: CGRect) { + layer.masksToBounds = true + layer.cornerRadius = em/2.5 + super.drawText(in: rect.inset(by: padding)) + } +} + + +// MARK: Percentage meter + +@IBDesignable +class MeterBar: UIView { + @IBInspectable var percent: CGFloat = 0 { didSet { setNeedsDisplay() } } + @IBInspectable var barColor: UIColor = .sysFg + @IBInspectable var horizontal: Bool = false + + private var normPercent: CGFloat { 1 - max(0, min(percent, 1)) } + + override func draw(_ rect: CGRect) { + let c = UIGraphicsGetCurrentContext() + c?.setFillColor(barColor.cgColor) + if horizontal { + c?.fill(rect.insetBy(dx: normPercent * (rect.width/2), dy: 0)) + } else { + c?.fill(rect.insetBy(dx: 0, dy: normPercent * (rect.height/2))) + } + } +} diff --git a/main/DB/DBAppOnly.swift b/main/DB/DBAppOnly.swift index 85aea61..10b0286 100644 --- a/main/DB/DBAppOnly.swift +++ b/main/DB/DBAppOnly.swift @@ -172,15 +172,11 @@ extension SQLiteDatabase { /// Group DNS logs by domain, count occurences and number of blocked requests. /// - Parameters: /// - range: Whenever possible set range to improve SQL lookup times. `start <= rowid <= end ` - /// - ts1: Restrict result set `ts >= ?` - /// - ts2: Restrict result set `ts < ?` /// - matchingDomain: Restrict `(fqdn|domain) = ?`. Which column is used is determined by `parentDomain`. /// - parentDomain: If `nil` returns `domain` column. Else returns `fqdn` column with restriction on `domain == parentDomain`. /// - Returns: List of grouped domains with no particular sorting order. - func dnsLogsGrouped(range: SQLiteRowRange = (0,0), since ts1: Timestamp = 0, upto ts2: Timestamp = 0, - matchingDomain: String? = nil, parentDomain: String? = nil) -> [GroupedDomain]? - { - let Where = WhereClauseBuilder().and(in: range).and(min: ts1, max: ts2) + func dnsLogsGrouped(range: SQLiteRowRange, matchingDomain: String? = nil, parentDomain: String? = nil) -> [GroupedDomain]? { + let Where = WhereClauseBuilder().and(in: range) let col: String // fqdn or domain if let parent = parentDomain { // is subdomain col = "fqdn" @@ -206,7 +202,7 @@ extension SQLiteDatabase { /// - fqdn: Exact match for domain name `fqdn = ?` /// - range: Whenever possible set range to improve SQL lookup times. `start <= rowid <= end ` /// - Returns: List sorted by reverse timestamp order (newest first) - func timesForDomain(_ fqdn: String, range: SQLiteRowRange = (0,0)) -> [GroupedTsOccurrence]? { + func timesForDomain(_ fqdn: String, range: SQLiteRowRange) -> [GroupedTsOccurrence]? { let Where = WhereClauseBuilder().and(in: range).and("fqdn = ?", BindText(fqdn)) return try? run(sql: "SELECT ts, COUNT(ts), COUNT(opt) FROM heap \(Where) GROUP BY ts ORDER BY ts DESC;", bind: Where.bindings) { allRows($0) { @@ -218,6 +214,71 @@ extension SQLiteDatabase { +// MARK: - Context Analysis + +typealias ContextAnalysisResult = (domain: String, count: Int32, avg: Double, rank: Double) + +extension SQLiteDatabase { + /// Number of times how often given `fqdn` appears in the database + func dnsLogsCount(fqdn: String) -> Int? { + try? run(sql: "SELECT COUNT(*) FROM heap WHERE fqdn = ?;", bind: [BindText(fqdn)]) { + try ifStep($0, SQLITE_ROW) + return Int(sqlite3_column_int($0, 0)) + } + } + + /// Get sorted, unique list of `ts` with given `fqdn`. + func dnsLogsUniqTs(_ fqdn: String) -> [Timestamp]? { + try? run(sql: "SELECT DISTINCT ts FROM heap WHERE fqdn = ? ORDER BY ts;", bind: [BindText(fqdn)]) { + allRows($0) { sqlite3_column_int64($0, 0) } + } + } + + /// Find other domains occurring regularly at roughly the same time as `fqdn`. + /// - Warning: `times` list must be **sorted** by time in ascending order. + /// - Parameters: + /// - times: List of `ts` from `dnsLogsUniqTs(fqdn)` + /// - dt: Search for `ts - dt <= X <= ts + dt` + /// - fqdn: Rows matching this domain will be excluded from the result set. + /// - Returns: List of tuples ordered by rank (ASC). + func contextAnalysis(coOccurrence times: [Timestamp], plusMinus dt: Timestamp, exclude fqdn: String) -> [ContextAnalysisResult]? { + guard times.count > 0 else { return nil } + createFunction("fnDist") { + let x = $0.first as! Timestamp + let i = times.binTreeIndex(of: x, compare: <)! + let dist: Timestamp + switch i { + case 0: dist = times[0] - x + case times.count: dist = x - times[i-1] + default: dist = min(times[i] - x, x - times[i-1]) + } + return dist + } + // `avg ^ 2`: prefer results that are closer to `times` + // `_ / count`: prefer results with higher occurrence count + // `time / 2`: Weighting factor (low: prefer close, high: prefer count) + // `time` helpful esp. for smaller spans. `avg^2` will raise faster anyway. + let fnRank = "(avg * avg + (? / 2.0) + 1) / count" // +1 in case time == 0 -> avg^2 == 0 + // improve query by excluding entries that are: before the first, or after the last ts + let low = times.first! - dt + let high = times.last! + dt + return try? run(sql: """ + SELECT fqdn, count, avg, (\(fnRank)) rank FROM ( + SELECT fqdn, COUNT(*) count, AVG(dist) avg FROM ( + SELECT fqdn, fnDist(ts) dist FROM heap + WHERE ts BETWEEN ? AND ? AND fqdn != ? AND dist <= ? + ) GROUP BY fqdn + ) ORDER BY rank ASC LIMIT 99; + """, bind: [BindInt64(dt), BindInt64(low), BindInt64(high), BindText(fqdn), BindInt64(dt)]) { + allRows($0) { + (readText($0, 0) ?? "", sqlite3_column_int($0, 1), sqlite3_column_double($0, 2), sqlite3_column_double($0, 3)) + } + } + } +} + + + // MARK: - Recordings extension CreateTable { diff --git a/main/DB/DBCore.swift b/main/DB/DBCore.swift index 8332033..7bd77ee 100644 --- a/main/DB/DBCore.swift +++ b/main/DB/DBCore.swift @@ -138,6 +138,7 @@ extension SQLiteDatabase { if let r = result as? Blob { sqlite3_result_blob(context, r.bytes, Int32(r.bytes.count), nil) } else if let r = result as? Double { sqlite3_result_double(context, r) } else if let r = result as? Int64 { sqlite3_result_int64(context, r) } + else if let r = result as? Bool { sqlite3_result_int(context, r ? 1 : 0) } else if let r = result as? String { sqlite3_result_text(context, r, Int32(r.count), SQLITE_TRANSIENT) } else if result == nil { sqlite3_result_null(context) } else { fatalError("unsupported result type: \(String(describing: result))") } diff --git a/main/Extensions/SharedState.swift b/main/Extensions/SharedState.swift index 89d7bdc..81062ec 100644 --- a/main/Extensions/SharedState.swift +++ b/main/Extensions/SharedState.swift @@ -25,6 +25,12 @@ enum Pref { set { Pref.Bool(newValue, "didShowTutorialRecordings") } } } + enum ContextAnalyis { + static var CoOccurrenceTime: Int? { + get { Pref.Any("contextAnalyisCoOccurrenceTime") as? Int } + set { Pref.Any(newValue, "contextAnalyisCoOccurrenceTime") } + } + } enum DateFilter { static var Kind: DateFilterKind { get { DateFilterKind(rawValue: Pref.Int("dateFilterType"))! } diff --git a/main/Extensions/TableView.swift b/main/Extensions/TableView.swift index 710c873..bedfbc0 100644 --- a/main/Extensions/TableView.swift +++ b/main/Extensions/TableView.swift @@ -43,6 +43,14 @@ extension UITableView { func safeMoveRow(_ from: Int, to: Int) { isFrontmost ? moveRow(at: IndexPath(row: from), to: IndexPath(row: to)) : reloadData() } + + /// Recalculate and apply new `tableHeaderView` height. + func sizeHeaderToFit() { + if let head = tableHeaderView { + head.frame.size.height = head.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height + tableHeaderView = head + } + } } diff --git a/main/Requests/Analytics/VCCoOccurrence.swift b/main/Requests/Analytics/VCCoOccurrence.swift new file mode 100644 index 0000000..8997cc5 --- /dev/null +++ b/main/Requests/Analytics/VCCoOccurrence.swift @@ -0,0 +1,88 @@ +import UIKit + +class VCCoOccurrence: UIViewController, UITableViewDataSource { + var fqdn: String! + private var dataSource: [ContextAnalysisResult] = [] + + @IBOutlet private var tableView: UITableView! + @IBOutlet private var timeSegment: UISegmentedControl! + private let availableTimes = [0, 5, 15, 30] + private var selectedTime = -1 { + didSet { logTimeDelta = log(CGFloat(max(2, selectedTime+1))) } + } + private var logTimeDelta: CGFloat = 1 + private var logMaxCount: CGFloat = 1 + + override func viewDidLoad() { + super.viewDidLoad() + selectedTime = Pref.ContextAnalyis.CoOccurrenceTime ?? 5 // calls `didSet` and `logTimeDelta` + timeSegment.removeAllSegments() // clear IB values + for (i, time) in availableTimes.enumerated() { + timeSegment.insertSegment(withTitle: TimeFormat(.abbreviated).from(seconds: time), at: i, animated: false) + if time == selectedTime { + timeSegment.selectedSegmentIndex = i + } + } + reloadDataSource() + } + + func reloadDataSource() { + dataSource = [("Loading …", 0, 0, 0)] + logMaxCount = 1 + tableView.reloadData() + let domain = fqdn! + let time = Timestamp(selectedTime) + DispatchQueue.global().async { [weak self] in + guard let db = AppDB, let times = db.dnsLogsUniqTs(domain), times.count > 0 else { + return // should never happen, or what did you tap then? + } + guard let result = db.contextAnalysis(coOccurrence: times, plusMinus: time, exclude: domain) else { + return + } + self?.dataSource = result + self?.logMaxCount = log(CGFloat(result.reduce(0) { max($0, $1.count) })) + DispatchQueue.main.sync { [weak self] in + self?.tableView.reloadData() + } + } + } + + @IBAction func didChangeTime(_ sender: UISegmentedControl) { + selectedTime = availableTimes[sender.selectedSegmentIndex] + Pref.ContextAnalyis.CoOccurrenceTime = selectedTime + reloadDataSource() + } + + @IBAction func didClose(_ sender: UIBarButtonItem) { + dismiss(animated: true) + } + + + // MARK: - Table View Data Source + + func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { + dataSource.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "CoOccurrenceCell") as! CoOccurrenceCell + let src = dataSource[indexPath.row] + cell.title.text = src.domain + cell.rank.text = "\(indexPath.row + 1)." + cell.count.text = "\(src.count)" + cell.avgdiff.text = String(format: "%.2fs", src.avg) + + cell.countMeter.percent = (log(CGFloat(src.count)) / logMaxCount) + cell.avgdiffMeter.percent = 1 - (log(CGFloat(src.avg + 1)) / logTimeDelta) + return cell + } +} + +class CoOccurrenceCell: UITableViewCell { + @IBOutlet var title: UILabel! + @IBOutlet var rank: TagLabel! + @IBOutlet var count: TagLabel! + @IBOutlet var avgdiff: TagLabel! + @IBOutlet var countMeter: MeterBar! + @IBOutlet var avgdiffMeter: MeterBar! +} diff --git a/main/Requests/TVCHostDetails.swift b/main/Requests/TVCHostDetails.swift index f693c9f..6fccfa7 100644 --- a/main/Requests/TVCHostDetails.swift +++ b/main/Requests/TVCHostDetails.swift @@ -1,7 +1,9 @@ import UIKit -class TVCHostDetails: UITableViewController, SyncUpdateDelegate { +class TVCHostDetails: UITableViewController, SyncUpdateDelegate, UITabBarDelegate { + @IBOutlet private var actionsBar: UITabBar! + public var fullDomain: String! private var dataSource: [GroupedTsOccurrence] = [] // TODO: respect date reverse sort order @@ -12,7 +14,9 @@ class TVCHostDetails: UITableViewController, SyncUpdateDelegate { sync.addObserver(self) // calls `syncUpdate(reset:)` if #available(iOS 10.0, *) { sync.allowPullToRefresh(onTVC: self, forObserver: self) + actionsBar.unselectedItemTintColor = .systemBlue } + UIDevice.orientationDidChangeNotification.observe(call: #selector(didChangeOrientation), on: self) } // MARK: - Table View Data Source @@ -29,6 +33,30 @@ class TVCHostDetails: UITableViewController, SyncUpdateDelegate { } } +// ######################### +// # +// # MARK: - Tab Bar +// # +// ######################### + +extension TVCHostDetails { + + @objc private func didChangeOrientation(_ sender: Notification) { + tableView.sizeHeaderToFit() // otherwise TabBar won't compress + } + + func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { + tabBar.selectedItem = nil + performSegue(withIdentifier: "segueAnalysisCoOccurrence", sender: nil) + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "segueAnalysisCoOccurrence" { + (segue.destination as? VCCoOccurrence)?.fqdn = fullDomain + } + } +} + // ################################ // # // # MARK: - Partial Update diff --git a/main/Requests/VCDateFilter.swift b/main/Requests/VCDateFilter.swift index faac79b..d49ee47 100644 --- a/main/Requests/VCDateFilter.swift +++ b/main/Requests/VCDateFilter.swift @@ -118,34 +118,3 @@ class VCDateFilter: UIViewController, UIGestureRecognizerDelegate { } } } - - -// MARK: White Triangle Popup Arrow - -@IBDesignable -class PopupTriangle: UIView { - @IBInspectable var rotation: CGFloat = 0 - @IBInspectable var color: UIColor = .black - - override func draw(_ rect: CGRect) { - guard let c = UIGraphicsGetCurrentContext() else { return } - let w = rect.width, h = rect.height - switch rotation { - case 90: // right - c.lineFromTo(x1: 0, y1: 0, x2: w, y2: h/2) - c.addLine(to: CGPoint(x: 0, y: h)) - case 180: // bottom - c.lineFromTo(x1: w, y1: 0, x2: w/2, y2: h) - c.addLine(to: CGPoint(x: 0, y: 0)) - case 270: // left - c.lineFromTo(x1: w, y1: h, x2: 0, y2: h/2) - c.addLine(to: CGPoint(x: w, y: 0)) - default: // top - c.lineFromTo(x1: 0, y1: h, x2: w/2, y2: 0) - c.addLine(to: CGPoint(x: w, y: h)) - } - c.closePath() - c.setFillColor(color.cgColor) - c.fillPath() - } -}