2712 lines
44 KiB
Python
2712 lines
44 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
|
||
"""
|
||
HTML report for one capture session.
|
||
|
||
Creates:
|
||
|
||
reports/sessions/<id>.html
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import html
|
||
import json
|
||
from pathlib import Path
|
||
|
||
import pynmea2
|
||
|
||
from reports.database import query_all, query_one
|
||
from reports.sessions import (
|
||
session_statistics,
|
||
trip_time,
|
||
session_directory_size,
|
||
coordinates_restored_count,
|
||
ap_without_coordinates_count,
|
||
)
|
||
from reports.utils import (
|
||
format_datetime as format_report_datetime,
|
||
format_duration,
|
||
format_size,
|
||
)
|
||
|
||
from reports.template import (
|
||
page_begin,
|
||
page_end,
|
||
page_header,
|
||
card,
|
||
ReportContext,
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Helpers
|
||
# ============================================================
|
||
|
||
|
||
def esc(value) -> str:
|
||
|
||
if value is None:
|
||
return ""
|
||
|
||
return html.escape(str(value))
|
||
|
||
|
||
def format_datetime(value):
|
||
return format_report_datetime(
|
||
value
|
||
)
|
||
|
||
|
||
def format_session_folder(name):
|
||
try:
|
||
from datetime import datetime, timezone
|
||
|
||
dt = datetime.strptime(
|
||
name,
|
||
"%Y%m%d_%H%M%S"
|
||
).replace(
|
||
tzinfo=timezone.utc
|
||
)
|
||
|
||
return format_report_datetime(
|
||
dt.isoformat()
|
||
)
|
||
|
||
except Exception:
|
||
return name
|
||
|
||
# ============================================================
|
||
# Session information
|
||
# ============================================================
|
||
|
||
|
||
def load_session(
|
||
conn,
|
||
session_id: int,
|
||
):
|
||
|
||
session = session_statistics(
|
||
conn,
|
||
session_id
|
||
)
|
||
|
||
if session is None:
|
||
|
||
raise RuntimeError(
|
||
f"Session {session_id} not found."
|
||
)
|
||
|
||
trip_start, trip_end = trip_time(
|
||
conn,
|
||
session_id
|
||
)
|
||
|
||
if trip_start:
|
||
session["trip_start"] = trip_start
|
||
|
||
if trip_end:
|
||
session["trip_end"] = trip_end
|
||
|
||
if trip_start and trip_end:
|
||
session["duration"] = format_duration(
|
||
trip_start,
|
||
trip_end
|
||
)
|
||
|
||
session["directory_size"] = format_size(
|
||
session_directory_size(
|
||
conn,
|
||
session_id
|
||
)
|
||
)
|
||
|
||
return session
|
||
|
||
def load_trip_information(
|
||
conn,
|
||
session_id,
|
||
):
|
||
row = query_one(
|
||
conn,
|
||
"""
|
||
SELECT
|
||
MIN(timestamp),
|
||
MAX(timestamp),
|
||
COUNT(*)
|
||
FROM gps_track
|
||
WHERE session_id=?
|
||
""",
|
||
(
|
||
session_id,
|
||
),
|
||
)
|
||
|
||
if (
|
||
row is None
|
||
or
|
||
row[0] is None
|
||
):
|
||
return {
|
||
"trip_start": None,
|
||
"trip_end": None,
|
||
"trip_points": 0,
|
||
}
|
||
|
||
return {
|
||
"trip_start": row[0],
|
||
"trip_end": row[1],
|
||
"trip_points": row[2],
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# GPS track
|
||
# ============================================================
|
||
|
||
|
||
def load_gps_track(
|
||
conn,
|
||
session_id,
|
||
):
|
||
rows = query_all(
|
||
conn,
|
||
"""
|
||
SELECT
|
||
latitude,
|
||
longitude
|
||
FROM gps_track
|
||
WHERE
|
||
session_id=?
|
||
ORDER BY
|
||
timestamp
|
||
""",
|
||
(
|
||
session_id,
|
||
),
|
||
)
|
||
|
||
track = []
|
||
|
||
for row in rows:
|
||
|
||
latitude = row[0]
|
||
longitude = row[1]
|
||
|
||
if (
|
||
latitude is None
|
||
or longitude is None
|
||
):
|
||
continue
|
||
|
||
if (
|
||
latitude == 0
|
||
or longitude == 0
|
||
):
|
||
continue
|
||
|
||
track.append(
|
||
{
|
||
"lat": latitude,
|
||
"lon": longitude,
|
||
}
|
||
)
|
||
|
||
return track
|
||
|
||
|
||
# ============================================================
|
||
# Session observations
|
||
# ============================================================
|
||
|
||
def load_session_observations(
|
||
|
||
conn,
|
||
session_id,
|
||
|
||
):
|
||
|
||
rows = query_all(
|
||
|
||
conn,
|
||
|
||
"""
|
||
|
||
SELECT
|
||
|
||
latitude,
|
||
longitude,
|
||
rssi,
|
||
observed_at,
|
||
|
||
id,
|
||
bssid,
|
||
essid,
|
||
vendor,
|
||
|
||
encryption,
|
||
channel,
|
||
frequency,
|
||
|
||
has_handshake,
|
||
has_pmkid,
|
||
is_cracked
|
||
|
||
FROM
|
||
|
||
(
|
||
|
||
SELECT
|
||
|
||
obs.latitude,
|
||
obs.longitude,
|
||
obs.rssi,
|
||
obs.observed_at,
|
||
|
||
ap.id,
|
||
ap.bssid,
|
||
ap.essid,
|
||
ap.vendor,
|
||
|
||
ap.encryption,
|
||
ap.channel,
|
||
ap.frequency,
|
||
|
||
CASE
|
||
WHEN hs.id IS NULL THEN 0
|
||
ELSE 1
|
||
END AS has_handshake,
|
||
|
||
ap.has_pmkid,
|
||
|
||
CASE
|
||
WHEN EXISTS
|
||
(
|
||
SELECT 1
|
||
FROM credentials AS c
|
||
JOIN handshakes AS ch
|
||
ON ch.id = c.handshake_id
|
||
WHERE
|
||
c.access_point_id = ap.id
|
||
AND ch.session_id = obs.session_id
|
||
)
|
||
THEN 1
|
||
ELSE 0
|
||
END AS is_cracked,
|
||
|
||
ROW_NUMBER() OVER
|
||
(
|
||
PARTITION BY obs.access_point_id
|
||
ORDER BY
|
||
obs.rssi DESC,
|
||
obs.observed_at DESC
|
||
) AS rn
|
||
|
||
FROM access_point_observations AS obs
|
||
|
||
JOIN access_points AS ap
|
||
ON ap.id = obs.access_point_id
|
||
|
||
LEFT JOIN handshakes AS hs
|
||
ON hs.access_point_id = ap.id
|
||
AND hs.session_id = obs.session_id
|
||
|
||
WHERE obs.session_id = ?
|
||
|
||
)
|
||
|
||
WHERE rn = 1
|
||
|
||
ORDER BY
|
||
|
||
has_handshake DESC,
|
||
|
||
is_cracked DESC,
|
||
|
||
rssi DESC
|
||
|
||
""",
|
||
|
||
(
|
||
|
||
session_id,
|
||
|
||
),
|
||
|
||
)
|
||
|
||
observations = []
|
||
|
||
for row in rows:
|
||
|
||
observations.append(
|
||
|
||
{
|
||
|
||
"latitude": row[0],
|
||
|
||
"longitude": row[1],
|
||
|
||
"rssi": row[2],
|
||
|
||
"observed_at": row[3],
|
||
|
||
"id": row[4],
|
||
|
||
"bssid": row[5],
|
||
|
||
"essid": row[6],
|
||
|
||
"vendor": row[7],
|
||
|
||
"encryption": row[8],
|
||
|
||
"channel": row[9],
|
||
|
||
"frequency": row[10],
|
||
|
||
"handshake": bool(row[11]),
|
||
|
||
"pmkid": bool(row[12]),
|
||
|
||
"cracked": bool(row[13]),
|
||
|
||
}
|
||
|
||
)
|
||
|
||
return observations
|
||
|
||
# ============================================================
|
||
# Summary
|
||
# ============================================================
|
||
|
||
|
||
def summary_block(
|
||
|
||
conn,
|
||
|
||
session,
|
||
|
||
observations,
|
||
|
||
):
|
||
|
||
access_points = len(observations)
|
||
|
||
handshake_ap = sum(
|
||
1
|
||
for ap in observations
|
||
if ap["handshake"]
|
||
)
|
||
|
||
handshake_records = session.get(
|
||
"handshake_records",
|
||
handshake_ap
|
||
)
|
||
|
||
pmkid_ap = sum(
|
||
1
|
||
for ap in observations
|
||
if ap["pmkid"]
|
||
)
|
||
|
||
cracked_ap = sum(
|
||
1
|
||
for ap in observations
|
||
if ap["cracked"]
|
||
)
|
||
|
||
coordinates_restored = session.get(
|
||
"coordinates_restored",
|
||
coordinates_restored_count(
|
||
conn,
|
||
session["id"]
|
||
)
|
||
)
|
||
|
||
ap_without_coordinates = session.get(
|
||
"ap_without_coordinates",
|
||
ap_without_coordinates_count(
|
||
conn,
|
||
session["id"]
|
||
)
|
||
)
|
||
|
||
html = f"""
|
||
|
||
<div class="stats-grid">
|
||
|
||
<div class="stat-card">
|
||
|
||
<div class="stat-value">
|
||
|
||
{access_points}
|
||
|
||
</div>
|
||
|
||
<div class="stat-title">
|
||
|
||
Access Points
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="stat-card">
|
||
|
||
<div class="stat-value">
|
||
|
||
{coordinates_restored}
|
||
|
||
</div>
|
||
|
||
<div class="stat-title">
|
||
|
||
Coordinates Restored
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="stat-card">
|
||
|
||
<div class="stat-value">
|
||
|
||
{ap_without_coordinates}
|
||
|
||
</div>
|
||
|
||
<div class="stat-title">
|
||
|
||
AP without Coordinates
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="stat-card">
|
||
|
||
<div class="stat-value">
|
||
|
||
{handshake_ap}
|
||
|
||
</div>
|
||
|
||
<div class="stat-title">
|
||
|
||
Handshake AP
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="stat-card">
|
||
|
||
<div class="stat-value">
|
||
|
||
{handshake_records}
|
||
|
||
</div>
|
||
|
||
<div class="stat-title">
|
||
|
||
Handshake Records
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="stat-card">
|
||
|
||
<div class="stat-value">
|
||
|
||
{pmkid_ap}
|
||
|
||
</div>
|
||
|
||
<div class="stat-title">
|
||
|
||
PMKID AP
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="stat-card">
|
||
|
||
<div class="stat-value">
|
||
|
||
{cracked_ap}
|
||
|
||
</div>
|
||
|
||
<div class="stat-title">
|
||
|
||
Cracked AP
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="stat-card">
|
||
|
||
<div class="stat-value">
|
||
|
||
{session.get("directory_size","-")}
|
||
|
||
</div>
|
||
|
||
<div class="stat-title">
|
||
|
||
Directory size
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
"""
|
||
|
||
return card(
|
||
|
||
"📊 Session Summary",
|
||
|
||
html,
|
||
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Files
|
||
# ============================================================
|
||
|
||
|
||
def files_block(
|
||
|
||
session,
|
||
|
||
):
|
||
|
||
capture = Path(
|
||
session["capture_file"]
|
||
)
|
||
|
||
directory = capture.parent
|
||
|
||
if not directory.exists():
|
||
|
||
html = """
|
||
<p>📁 Каталог файлов этой поездки отсутствует.</p>
|
||
|
||
<p>
|
||
Возможно, он был удалён пользователем вручную.
|
||
WiFi GPS Mapper не удаляет каталоги поездок автоматически.
|
||
Все данные сохранены в базе данных
|
||
</p>
|
||
"""
|
||
|
||
else:
|
||
|
||
html = """
|
||
|
||
<table>
|
||
|
||
<tr>
|
||
|
||
<th>File</th>
|
||
|
||
<th>Exists</th>
|
||
|
||
</tr>
|
||
|
||
"""
|
||
|
||
files = [
|
||
|
||
"capture.pcapng",
|
||
|
||
"gps_track.nmea",
|
||
|
||
"access_points.csv",
|
||
|
||
"handshakes.22000",
|
||
|
||
"essid.txt",
|
||
|
||
"parser.log",
|
||
|
||
]
|
||
|
||
for name in files:
|
||
|
||
filename = directory / name
|
||
|
||
html += f"""
|
||
|
||
<tr>
|
||
|
||
<td>{name}</td>
|
||
|
||
<td>{"✅" if filename.exists() else "—"}</td>
|
||
|
||
</tr>
|
||
|
||
"""
|
||
|
||
html += """
|
||
|
||
</table>
|
||
|
||
"""
|
||
|
||
return card(
|
||
|
||
"📂 Session Files",
|
||
|
||
html,
|
||
|
||
)
|
||
|
||
# ============================================================
|
||
# JavaScript
|
||
# ============================================================
|
||
|
||
|
||
def build_track_js(
|
||
|
||
track,
|
||
|
||
):
|
||
|
||
if not track:
|
||
|
||
return "const track = [];"
|
||
|
||
return (
|
||
|
||
"const track = "
|
||
|
||
+ json.dumps(track)
|
||
|
||
+ ";"
|
||
|
||
)
|
||
|
||
|
||
def build_marker_js(
|
||
|
||
observations,
|
||
|
||
):
|
||
|
||
markers = []
|
||
|
||
for ap in observations:
|
||
|
||
if (
|
||
ap["latitude"] is None
|
||
or
|
||
ap["longitude"] is None
|
||
):
|
||
continue
|
||
|
||
popup = f"""
|
||
|
||
<b>
|
||
|
||
{esc(ap["essid"])}
|
||
|
||
</b>
|
||
|
||
<br>
|
||
|
||
{esc(ap["bssid"])}
|
||
|
||
<br>
|
||
|
||
Vendor:
|
||
{esc(ap["vendor"])}
|
||
|
||
<br>
|
||
|
||
Encryption:
|
||
{esc(ap["encryption"])}
|
||
|
||
<br>
|
||
|
||
Channel:
|
||
{esc(ap["channel"])}
|
||
|
||
|
||
|
||
Frequency:
|
||
{esc(ap["frequency"])}
|
||
|
||
<br>
|
||
|
||
RSSI:
|
||
{esc(ap["rssi"])} dBm
|
||
|
||
<br>
|
||
|
||
Handshake:
|
||
{"✅" if ap["handshake"] else "—"}
|
||
|
||
|
||
|
||
PMKID:
|
||
{"✅" if ap["pmkid"] else "—"}
|
||
|
||
|
||
|
||
Password:
|
||
{"✅" if ap["cracked"] else "—"}
|
||
|
||
"""
|
||
|
||
markers.append(
|
||
|
||
{
|
||
|
||
"lat": ap["latitude"],
|
||
|
||
"lon": ap["longitude"],
|
||
|
||
"popup": popup,
|
||
|
||
"handshake": ap["handshake"],
|
||
|
||
"pmkid": ap["pmkid"],
|
||
|
||
"cracked": ap["cracked"],
|
||
|
||
}
|
||
|
||
)
|
||
|
||
return (
|
||
|
||
"const markers = "
|
||
|
||
+ json.dumps(
|
||
markers,
|
||
ensure_ascii=False,
|
||
)
|
||
|
||
+ ";"
|
||
|
||
)
|
||
|
||
# ============================================================
|
||
# Map
|
||
# ============================================================
|
||
|
||
|
||
def map_block(
|
||
|
||
track,
|
||
|
||
observations,
|
||
|
||
context=None,
|
||
|
||
):
|
||
|
||
track_data = [
|
||
|
||
[
|
||
float(p["lat"]),
|
||
float(p["lon"])
|
||
]
|
||
|
||
for p in track
|
||
|
||
if (
|
||
p.get("lat") is not None
|
||
and
|
||
p.get("lon") is not None
|
||
and
|
||
p.get("lat") != 0
|
||
and
|
||
p.get("lon") != 0
|
||
)
|
||
]
|
||
|
||
marker_js = build_marker_js(
|
||
observations
|
||
)
|
||
|
||
maplibre_css_url = context.asset_url(
|
||
"maplibre/maplibre-gl.css"
|
||
)
|
||
|
||
maplibre_js_url = context.asset_url(
|
||
"maplibre/maplibre-gl.js"
|
||
)
|
||
|
||
html = f"""
|
||
|
||
<div id="map"></div>
|
||
|
||
<link
|
||
|
||
rel="stylesheet"
|
||
|
||
href="{maplibre_css_url}"
|
||
|
||
>
|
||
|
||
<script
|
||
|
||
src="{maplibre_js_url}">
|
||
|
||
</script>
|
||
|
||
<script>
|
||
|
||
"""
|
||
|
||
html += (
|
||
"const track = "
|
||
+ json.dumps(
|
||
track_data,
|
||
ensure_ascii=False,
|
||
)
|
||
+ ";"
|
||
+ marker_js
|
||
)
|
||
|
||
html += """
|
||
|
||
// ======================================================
|
||
// MapLibre map
|
||
// ======================================================
|
||
|
||
const map = new maplibregl.Map({
|
||
|
||
container: "map",
|
||
|
||
style:
|
||
|
||
"http://127.0.0.1:8080/styles/server-map/style.json",
|
||
|
||
center: [
|
||
|
||
30.32,
|
||
|
||
59.93
|
||
|
||
],
|
||
|
||
zoom: 12,
|
||
|
||
attributionControl: true
|
||
|
||
});
|
||
|
||
|
||
// ======================================================
|
||
// Navigation
|
||
// ======================================================
|
||
|
||
map.addControl(
|
||
|
||
new maplibregl.NavigationControl(),
|
||
|
||
"top-right"
|
||
|
||
);
|
||
|
||
|
||
// ======================================================
|
||
// Scale
|
||
// ======================================================
|
||
|
||
map.addControl(
|
||
|
||
new maplibregl.ScaleControl({
|
||
|
||
maxWidth:120,
|
||
|
||
unit:"metric"
|
||
|
||
})
|
||
|
||
);
|
||
|
||
|
||
// ======================================================
|
||
// Data layers
|
||
// ======================================================
|
||
|
||
const layerState = {
|
||
gps:true,
|
||
handshake:true,
|
||
password:true,
|
||
all:false
|
||
};
|
||
|
||
|
||
// ======================================================
|
||
// GPS track
|
||
// ======================================================
|
||
|
||
map.on(
|
||
|
||
"load",
|
||
|
||
function()
|
||
|
||
{
|
||
|
||
map.addSource(
|
||
|
||
"gps-track",
|
||
|
||
{
|
||
|
||
type:"geojson",
|
||
|
||
data:{
|
||
|
||
type:"Feature",
|
||
|
||
geometry:{
|
||
|
||
type:"LineString",
|
||
|
||
coordinates:
|
||
|
||
track.map(
|
||
|
||
function(p)
|
||
|
||
{
|
||
|
||
return [
|
||
|
||
p[1],
|
||
|
||
p[0]
|
||
|
||
];
|
||
|
||
}
|
||
|
||
)
|
||
|
||
}
|
||
|
||
}
|
||
|
||
}
|
||
|
||
);
|
||
|
||
|
||
map.addLayer({
|
||
|
||
id:"gps-track",
|
||
|
||
type:"line",
|
||
|
||
source:"gps-track",
|
||
|
||
layout:{
|
||
|
||
visibility:
|
||
|
||
track.length > 0
|
||
|
||
? "visible"
|
||
|
||
: "none"
|
||
|
||
},
|
||
|
||
paint:{
|
||
|
||
"line-color":"#1565C0",
|
||
|
||
"line-width":4,
|
||
|
||
"line-opacity":0.85
|
||
|
||
}
|
||
|
||
});
|
||
|
||
|
||
if(track.length > 1)
|
||
{
|
||
|
||
map.addSource(
|
||
|
||
"gps-start",
|
||
|
||
{
|
||
|
||
type:"geojson",
|
||
|
||
data:{
|
||
|
||
type:"Feature",
|
||
|
||
geometry:{
|
||
|
||
type:"Point",
|
||
|
||
coordinates:[
|
||
|
||
track[0][1],
|
||
|
||
track[0][0]
|
||
|
||
]
|
||
|
||
}
|
||
|
||
}
|
||
|
||
}
|
||
|
||
);
|
||
|
||
|
||
map.addSource(
|
||
|
||
"gps-end",
|
||
|
||
{
|
||
|
||
type:"geojson",
|
||
|
||
data:{
|
||
|
||
type:"Feature",
|
||
|
||
geometry:{
|
||
|
||
type:"Point",
|
||
|
||
coordinates:[
|
||
|
||
track[track.length - 1][1],
|
||
|
||
track[track.length - 1][0]
|
||
|
||
]
|
||
|
||
}
|
||
|
||
}
|
||
|
||
}
|
||
|
||
);
|
||
|
||
|
||
map.addLayer({
|
||
|
||
id:"gps-start",
|
||
|
||
type:"circle",
|
||
|
||
source:"gps-start",
|
||
|
||
paint:{
|
||
|
||
"circle-radius":8,
|
||
|
||
"circle-color":"#00C853",
|
||
|
||
"circle-stroke-color":"#ffffff",
|
||
|
||
"circle-stroke-width":2
|
||
|
||
}
|
||
|
||
});
|
||
|
||
|
||
map.addLayer({
|
||
|
||
id:"gps-end",
|
||
|
||
type:"circle",
|
||
|
||
source:"gps-end",
|
||
|
||
paint:{
|
||
|
||
"circle-radius":8,
|
||
|
||
"circle-color":"#D50000",
|
||
|
||
"circle-stroke-color":"#ffffff",
|
||
|
||
"circle-stroke-width":2
|
||
|
||
}
|
||
|
||
});
|
||
|
||
|
||
const startEl =
|
||
document.createElement("div");
|
||
|
||
startEl.style.width =
|
||
"22px";
|
||
|
||
startEl.style.height =
|
||
"22px";
|
||
|
||
startEl.style.borderRadius =
|
||
"50%";
|
||
|
||
startEl.style.background =
|
||
"#00C853";
|
||
|
||
startEl.style.border =
|
||
"3px solid white";
|
||
|
||
startEl.style.boxShadow =
|
||
"0 0 8px rgba(0,0,0,.5)";
|
||
|
||
startEl.style.display =
|
||
"flex";
|
||
|
||
startEl.style.alignItems =
|
||
"center";
|
||
|
||
startEl.style.justifyContent =
|
||
"center";
|
||
|
||
startEl.style.color =
|
||
"white";
|
||
|
||
startEl.style.fontWeight =
|
||
"bold";
|
||
|
||
startEl.innerText =
|
||
"S";
|
||
|
||
|
||
new maplibregl.Marker({
|
||
element:startEl
|
||
})
|
||
.setLngLat([
|
||
track[0][1],
|
||
track[0][0]
|
||
])
|
||
.setPopup(
|
||
new maplibregl.Popup({
|
||
offset:25
|
||
})
|
||
.setHTML(
|
||
"<b>Start of trip</b>"
|
||
)
|
||
)
|
||
.addTo(map);
|
||
|
||
|
||
const endEl =
|
||
document.createElement("div");
|
||
|
||
endEl.style.width =
|
||
"22px";
|
||
|
||
endEl.style.height =
|
||
"22px";
|
||
|
||
endEl.style.borderRadius =
|
||
"50%";
|
||
|
||
endEl.style.background =
|
||
"#D50000";
|
||
|
||
endEl.style.border =
|
||
"3px solid white";
|
||
|
||
endEl.style.boxShadow =
|
||
"0 0 8px rgba(0,0,0,.5)";
|
||
|
||
endEl.style.display =
|
||
"flex";
|
||
|
||
endEl.style.alignItems =
|
||
"center";
|
||
|
||
endEl.style.justifyContent =
|
||
"center";
|
||
|
||
endEl.style.color =
|
||
"white";
|
||
|
||
endEl.style.fontWeight =
|
||
"bold";
|
||
|
||
endEl.innerText =
|
||
"F";
|
||
|
||
|
||
new maplibregl.Marker({
|
||
element:endEl
|
||
})
|
||
.setLngLat([
|
||
track[track.length - 1][1],
|
||
track[track.length - 1][0]
|
||
])
|
||
.setPopup(
|
||
new maplibregl.Popup({
|
||
offset:25
|
||
})
|
||
.setHTML(
|
||
"<b>End of trip</b>"
|
||
)
|
||
)
|
||
.addTo(map);
|
||
|
||
map.addLayer({
|
||
|
||
id:"gps-direction",
|
||
|
||
type:"symbol",
|
||
|
||
source:"gps-track",
|
||
|
||
layout:{
|
||
|
||
"symbol-placement":"line",
|
||
|
||
"symbol-spacing":80,
|
||
|
||
"text-field":"▶",
|
||
|
||
"text-size":16,
|
||
|
||
"text-keep-upright":false,
|
||
|
||
"text-allow-overlap":true,
|
||
|
||
"text-ignore-placement":true,
|
||
|
||
"text-offset":[
|
||
0,
|
||
-0.14
|
||
]
|
||
|
||
},
|
||
|
||
paint:{
|
||
|
||
"text-color":"#1565C0",
|
||
|
||
"text-halo-color":"#ffffff",
|
||
|
||
"text-halo-width":1
|
||
|
||
}
|
||
|
||
});
|
||
|
||
}
|
||
|
||
|
||
// ==================================================
|
||
// Access Points
|
||
// ==================================================
|
||
|
||
const apFeatures = [];
|
||
const handshakeFeatures = [];
|
||
const pmkidFeatures = [];
|
||
const passwordFeatures = [];
|
||
|
||
|
||
markers.forEach(
|
||
function(ap)
|
||
{
|
||
const feature = {
|
||
type:"Feature",
|
||
geometry:{
|
||
type:"Point",
|
||
coordinates:[
|
||
ap.lon,
|
||
ap.lat
|
||
]
|
||
},
|
||
properties:{
|
||
popup:ap.popup || "",
|
||
handshake:ap.handshake,
|
||
pmkid:ap.pmkid,
|
||
cracked:ap.cracked
|
||
}
|
||
};
|
||
|
||
|
||
// All Access Points source.
|
||
// Every AP belongs here so that clustering
|
||
// represents the complete AP population.
|
||
apFeatures.push(
|
||
feature
|
||
);
|
||
|
||
|
||
if(ap.cracked)
|
||
{
|
||
passwordFeatures.push(
|
||
feature
|
||
);
|
||
}
|
||
|
||
else if(ap.pmkid)
|
||
{
|
||
pmkidFeatures.push(
|
||
feature
|
||
);
|
||
}
|
||
|
||
else if(ap.handshake)
|
||
{
|
||
handshakeFeatures.push(
|
||
feature
|
||
);
|
||
}
|
||
}
|
||
);
|
||
|
||
|
||
const createSource =
|
||
|
||
function(
|
||
|
||
id,
|
||
|
||
features
|
||
|
||
)
|
||
|
||
{
|
||
|
||
map.addSource(
|
||
|
||
id,
|
||
|
||
{
|
||
|
||
type:"geojson",
|
||
|
||
data:{
|
||
|
||
type:"FeatureCollection",
|
||
|
||
features:features
|
||
|
||
},
|
||
|
||
cluster:
|
||
|
||
id === "aps",
|
||
|
||
clusterMaxZoom:17,
|
||
|
||
clusterRadius:50
|
||
|
||
}
|
||
|
||
);
|
||
|
||
};
|
||
|
||
|
||
createSource(
|
||
|
||
"aps",
|
||
|
||
apFeatures
|
||
|
||
);
|
||
|
||
createSource(
|
||
|
||
"handshakes",
|
||
|
||
handshakeFeatures
|
||
|
||
);
|
||
|
||
createSource(
|
||
|
||
"pmkids",
|
||
|
||
pmkidFeatures
|
||
|
||
);
|
||
|
||
createSource(
|
||
|
||
"passwords",
|
||
|
||
passwordFeatures
|
||
|
||
);
|
||
|
||
|
||
// ==================================================
|
||
// AP clusters
|
||
// ==================================================
|
||
|
||
map.addLayer({
|
||
|
||
id:"ap-clusters",
|
||
|
||
type:"circle",
|
||
|
||
source:"aps",
|
||
|
||
filter:[
|
||
|
||
"has",
|
||
|
||
"point_count"
|
||
|
||
],
|
||
|
||
layout:{
|
||
|
||
visibility:
|
||
|
||
"none"
|
||
|
||
},
|
||
|
||
paint:{
|
||
|
||
// ==================================================
|
||
// Цвет кластера в зависимости от количества точек доступа (AP)
|
||
//
|
||
// Первый цвет используется при значениях ниже первого порога.
|
||
//
|
||
// 1–9 AP -> зеленый
|
||
// 10–49 AP -> желтый
|
||
// 50–99 AP -> оранжевый
|
||
// 100+ AP -> красный
|
||
//
|
||
// Для изменения порогового значения:
|
||
// 10 = кластер становится желтым при достижении 10 AP
|
||
// 50 = кластер становится оранжевым при достижении 50 AP
|
||
// 100 = кластер становится красным при достижении 100 AP
|
||
//
|
||
// Для изменения цвета замените соответствующее HEX-значение.
|
||
// ==================================================
|
||
|
||
"circle-color":[
|
||
"step",
|
||
[
|
||
"get",
|
||
"point_count"
|
||
],
|
||
|
||
// Default color: fewer than 10 APs
|
||
"#43A047",
|
||
|
||
// 10 APs and more
|
||
10,
|
||
"#FDD835",
|
||
|
||
// 50 APs and more
|
||
50,
|
||
"#FB8C00",
|
||
|
||
// 100 APs and more
|
||
100,
|
||
"#E53935"
|
||
],
|
||
|
||
"circle-radius":[
|
||
"step",
|
||
[
|
||
"get",
|
||
"point_count"
|
||
],
|
||
18,
|
||
50,
|
||
22,
|
||
100,
|
||
28
|
||
],
|
||
|
||
"circle-stroke-color":"#ffffff",
|
||
"circle-stroke-width":2
|
||
}
|
||
|
||
});
|
||
|
||
|
||
map.addLayer({
|
||
id:"ap-cluster-count",
|
||
type:"symbol",
|
||
source:"aps",
|
||
filter:[
|
||
"has",
|
||
"point_count"
|
||
],
|
||
layout:{
|
||
visibility:
|
||
"none",
|
||
"text-field":
|
||
"{point_count_abbreviated}",
|
||
"text-size":12
|
||
},
|
||
paint:{
|
||
"text-color":"#ffffff"
|
||
}
|
||
});
|
||
|
||
|
||
map.addLayer({
|
||
id:"ap-points",
|
||
type:"circle",
|
||
source:"aps",
|
||
filter:[
|
||
"!",
|
||
[
|
||
"has",
|
||
"point_count"
|
||
]
|
||
],
|
||
layout:{
|
||
visibility:
|
||
"none"
|
||
},
|
||
paint:{
|
||
"circle-radius":7,
|
||
"circle-color":"#43A047",
|
||
"circle-stroke-color":"#ffffff",
|
||
"circle-stroke-width":2
|
||
}
|
||
});
|
||
|
||
|
||
// ==================================================
|
||
// Special AP layers
|
||
// ==================================================
|
||
|
||
const addPointLayer =
|
||
|
||
function(
|
||
|
||
id,
|
||
|
||
source,
|
||
|
||
color
|
||
|
||
)
|
||
|
||
{
|
||
|
||
map.addLayer({
|
||
|
||
id:id,
|
||
|
||
type:"circle",
|
||
|
||
source:source,
|
||
|
||
paint:{
|
||
|
||
"circle-radius":6,
|
||
|
||
"circle-color":color,
|
||
|
||
"circle-opacity":0.9,
|
||
|
||
"circle-stroke-color":
|
||
|
||
"#ffffff",
|
||
|
||
"circle-stroke-width":2
|
||
|
||
}
|
||
|
||
});
|
||
|
||
};
|
||
|
||
|
||
addPointLayer(
|
||
|
||
"handshake-points",
|
||
|
||
"handshakes",
|
||
|
||
"#E5C07B"
|
||
|
||
);
|
||
|
||
|
||
addPointLayer(
|
||
|
||
"pmkid-points",
|
||
|
||
"pmkids",
|
||
|
||
"#FB8C00"
|
||
|
||
);
|
||
|
||
|
||
addPointLayer(
|
||
|
||
"password-points",
|
||
|
||
"passwords",
|
||
|
||
"#E53935"
|
||
|
||
);
|
||
|
||
|
||
// ==================================================
|
||
// Popups
|
||
// ==================================================
|
||
|
||
const popupLayers = [
|
||
|
||
"ap-points",
|
||
|
||
"handshake-points",
|
||
|
||
"pmkid-points",
|
||
|
||
"password-points"
|
||
|
||
];
|
||
|
||
|
||
popupLayers.forEach(
|
||
function(layerId)
|
||
{
|
||
map.on(
|
||
"click",
|
||
layerId,
|
||
function(e)
|
||
{
|
||
if(
|
||
!e.features
|
||
||
|
||
!e.features.length
|
||
)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const feature =
|
||
e.features[0];
|
||
|
||
if(
|
||
!feature
|
||
||
|
||
!feature.properties
|
||
)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const popup =
|
||
feature.properties.popup
|
||
||
|
||
"<b>Access Point</b>";
|
||
|
||
new maplibregl.Popup()
|
||
.setLngLat(
|
||
feature.geometry.coordinates
|
||
)
|
||
.setHTML(
|
||
popup
|
||
)
|
||
.addTo(map);
|
||
}
|
||
);
|
||
|
||
map.on(
|
||
"mouseenter",
|
||
layerId,
|
||
function()
|
||
{
|
||
map.getCanvas().style.cursor =
|
||
"pointer";
|
||
}
|
||
);
|
||
|
||
map.on(
|
||
"mouseleave",
|
||
layerId,
|
||
function()
|
||
{
|
||
map.getCanvas().style.cursor =
|
||
"";
|
||
}
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
// ==================================================
|
||
// Cluster click
|
||
// ==================================================
|
||
|
||
map.on(
|
||
"click",
|
||
"ap-clusters",
|
||
function(e)
|
||
{
|
||
const features =
|
||
map.queryRenderedFeatures(
|
||
e.point,
|
||
{
|
||
layers:[
|
||
"ap-clusters"
|
||
]
|
||
}
|
||
);
|
||
|
||
if(
|
||
!features.length
|
||
)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const clusterId =
|
||
features[0].properties
|
||
.cluster_id;
|
||
|
||
map.getSource("aps")
|
||
.getClusterExpansionZoom(
|
||
clusterId,
|
||
function(
|
||
error,
|
||
zoom
|
||
)
|
||
{
|
||
if(error)
|
||
{
|
||
return;
|
||
}
|
||
|
||
map.easeTo({
|
||
center:
|
||
features[0]
|
||
.geometry
|
||
.coordinates,
|
||
|
||
zoom:zoom
|
||
});
|
||
}
|
||
);
|
||
}
|
||
);
|
||
|
||
map.on(
|
||
"mouseenter",
|
||
"ap-clusters",
|
||
function()
|
||
{
|
||
map.getCanvas().style.cursor =
|
||
"pointer";
|
||
}
|
||
);
|
||
|
||
map.on(
|
||
"mouseleave",
|
||
"ap-clusters",
|
||
function()
|
||
{
|
||
map.getCanvas().style.cursor =
|
||
"";
|
||
}
|
||
);
|
||
|
||
|
||
// ==================================================
|
||
// Layer visibility
|
||
// ==================================================
|
||
|
||
const setLayerVisibility =
|
||
|
||
function(
|
||
|
||
ids,
|
||
|
||
visible
|
||
|
||
)
|
||
|
||
{
|
||
|
||
ids.forEach(
|
||
|
||
function(id)
|
||
|
||
{
|
||
|
||
if(
|
||
|
||
map.getLayer(id)
|
||
|
||
)
|
||
|
||
{
|
||
|
||
map.setLayoutProperty(
|
||
|
||
id,
|
||
|
||
"visibility",
|
||
|
||
visible
|
||
|
||
? "visible"
|
||
|
||
: "none"
|
||
|
||
);
|
||
|
||
}
|
||
|
||
}
|
||
|
||
);
|
||
|
||
};
|
||
|
||
|
||
// ==================================================
|
||
// Layer switcher
|
||
// ==================================================
|
||
|
||
document.querySelectorAll(
|
||
".session-map-controls input[data-layer]"
|
||
).forEach(
|
||
function(input)
|
||
{
|
||
input.addEventListener(
|
||
"change",
|
||
function()
|
||
{
|
||
const layer =
|
||
input.dataset.layer;
|
||
|
||
|
||
layerState[layer] =
|
||
input.checked;
|
||
|
||
|
||
if(layer === "gps")
|
||
{
|
||
setLayerVisibility(
|
||
[
|
||
"gps-track"
|
||
],
|
||
input.checked
|
||
);
|
||
}
|
||
|
||
|
||
if(layer === "handshake")
|
||
{
|
||
setLayerVisibility(
|
||
[
|
||
"handshake-points",
|
||
"pmkid-points"
|
||
],
|
||
input.checked
|
||
);
|
||
}
|
||
|
||
|
||
if(layer === "password")
|
||
{
|
||
setLayerVisibility(
|
||
[
|
||
"password-points"
|
||
],
|
||
input.checked
|
||
);
|
||
}
|
||
|
||
|
||
if(layer === "all")
|
||
{
|
||
setLayerVisibility(
|
||
[
|
||
"ap-points",
|
||
"ap-clusters",
|
||
"ap-cluster-count"
|
||
],
|
||
input.checked
|
||
);
|
||
}
|
||
}
|
||
);
|
||
}
|
||
);
|
||
|
||
|
||
// ==================================================
|
||
// Initial map position
|
||
// ==================================================
|
||
|
||
if(track.length > 0)
|
||
|
||
{
|
||
|
||
map.jumpTo({
|
||
|
||
center:[
|
||
|
||
track[0][1],
|
||
|
||
track[0][0]
|
||
|
||
],
|
||
|
||
zoom:14
|
||
|
||
});
|
||
|
||
}
|
||
|
||
}
|
||
|
||
);
|
||
|
||
|
||
// ======================================================
|
||
// Resize
|
||
// ======================================================
|
||
|
||
window.setTimeout(
|
||
|
||
function()
|
||
|
||
{
|
||
|
||
map.resize();
|
||
|
||
},
|
||
|
||
100
|
||
|
||
);
|
||
|
||
</script>
|
||
|
||
"""
|
||
|
||
title = """
|
||
<span class="session-map-title">
|
||
<span class="session-map-title-text">
|
||
🗺 Capture Map
|
||
</span>
|
||
|
||
<span class="session-map-controls">
|
||
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
data-layer="gps"
|
||
checked
|
||
>
|
||
<span
|
||
class="map-layer-dot map-layer-dot-gps"
|
||
></span>
|
||
GPS Track
|
||
</label>
|
||
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
data-layer="handshake"
|
||
checked
|
||
>
|
||
<span
|
||
class="map-layer-dot map-layer-dot-handshake"
|
||
></span>
|
||
Handshake / PMKID
|
||
</label>
|
||
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
data-layer="password"
|
||
checked
|
||
>
|
||
<span
|
||
class="map-layer-dot map-layer-dot-password"
|
||
></span>
|
||
Password
|
||
</label>
|
||
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
data-layer="all"
|
||
>
|
||
<span
|
||
class="map-layer-dot map-layer-dot-all"
|
||
></span>
|
||
All Access Points
|
||
</label>
|
||
|
||
</span>
|
||
</span>
|
||
"""
|
||
|
||
|
||
return card(
|
||
|
||
title,
|
||
|
||
html,
|
||
|
||
)
|
||
|
||
# ============================================================
|
||
# Access Points
|
||
# ============================================================
|
||
|
||
def access_points_block(
|
||
|
||
observations,
|
||
|
||
):
|
||
|
||
html = """
|
||
|
||
<table id="access_points">
|
||
|
||
<tr>
|
||
|
||
<th>BSSID</th>
|
||
|
||
<th>ESSID</th>
|
||
|
||
<th>Vendor</th>
|
||
|
||
<th>Encryption</th>
|
||
|
||
<th>Channel</th>
|
||
|
||
<th>RSSI</th>
|
||
|
||
<th>HS</th>
|
||
|
||
<th>PMKID</th>
|
||
|
||
<th>PASS</th>
|
||
|
||
</tr>
|
||
|
||
"""
|
||
|
||
handshake_points = [
|
||
ap
|
||
for ap in observations
|
||
if ap["handshake"]
|
||
]
|
||
|
||
for ap in handshake_points:
|
||
|
||
html += f"""
|
||
|
||
<tr>
|
||
|
||
<td>
|
||
|
||
{esc(ap["bssid"])}
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
{esc(ap["essid"])}
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
{esc(ap["vendor"])}
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
{esc(ap["encryption"])}
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
{esc(ap["channel"])}
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
{esc(ap["rssi"])}
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
🤝
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
{"🟠" if ap["pmkid"] else ""}
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
{"🔑" if ap["cracked"] else ""}
|
||
|
||
</td>
|
||
|
||
</tr>
|
||
|
||
"""
|
||
|
||
html += """
|
||
|
||
</table>
|
||
|
||
"""
|
||
|
||
return card(
|
||
|
||
f"📡 Access Points Handshake ({len(handshake_points)})",
|
||
|
||
html,
|
||
|
||
)
|
||
|
||
# ============================================================
|
||
# Credentials
|
||
# ============================================================
|
||
|
||
def credentials_block(
|
||
|
||
conn,
|
||
|
||
session_id,
|
||
|
||
):
|
||
|
||
rows = query_all(
|
||
|
||
conn,
|
||
|
||
"""
|
||
SELECT
|
||
|
||
ap.bssid,
|
||
ap.essid,
|
||
|
||
c.password,
|
||
c.source,
|
||
|
||
c.created_at,
|
||
c.verified
|
||
|
||
FROM credentials c
|
||
|
||
JOIN access_points ap
|
||
|
||
ON ap.id=c.access_point_id
|
||
|
||
JOIN handshakes hs
|
||
|
||
ON hs.id=c.handshake_id
|
||
|
||
WHERE
|
||
|
||
hs.session_id=?
|
||
|
||
GROUP BY
|
||
|
||
c.id
|
||
|
||
ORDER BY
|
||
|
||
c.created_at DESC
|
||
|
||
""",
|
||
|
||
(
|
||
|
||
session_id,
|
||
|
||
),
|
||
|
||
)
|
||
|
||
if not rows:
|
||
|
||
return ""
|
||
|
||
html = """
|
||
|
||
<table>
|
||
|
||
<tr>
|
||
|
||
<th>BSSID</th>
|
||
|
||
<th>ESSID</th>
|
||
|
||
<th>Password</th>
|
||
|
||
<th>Source</th>
|
||
|
||
<th>Verified</th>
|
||
|
||
</tr>
|
||
|
||
"""
|
||
|
||
for row in rows:
|
||
|
||
(
|
||
|
||
bssid,
|
||
|
||
essid,
|
||
|
||
password,
|
||
|
||
source,
|
||
|
||
created,
|
||
|
||
verified,
|
||
|
||
) = row
|
||
|
||
html += f"""
|
||
|
||
<tr>
|
||
|
||
<td>{esc(bssid)}</td>
|
||
|
||
<td>{esc(essid)}</td>
|
||
|
||
<td>
|
||
|
||
<b>
|
||
|
||
{esc(password)}
|
||
|
||
</b>
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
{esc(source)}
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
{"✅" if verified else ""}
|
||
|
||
</td>
|
||
|
||
</tr>
|
||
|
||
"""
|
||
|
||
html += """
|
||
|
||
</table>
|
||
|
||
"""
|
||
|
||
return card(
|
||
|
||
f"🔑 Credentials ({len(rows)})",
|
||
|
||
html,
|
||
|
||
)
|
||
|
||
# ============================================================
|
||
# Handshakes
|
||
# ============================================================
|
||
|
||
def handshakes_block(
|
||
|
||
conn,
|
||
|
||
session_id,
|
||
|
||
):
|
||
|
||
rows = query_all(
|
||
|
||
conn,
|
||
|
||
"""
|
||
SELECT
|
||
|
||
ap.bssid,
|
||
ap.essid,
|
||
|
||
h.type,
|
||
h.message_pair,
|
||
|
||
h.captured_at
|
||
|
||
FROM handshakes h
|
||
|
||
JOIN access_points ap
|
||
|
||
ON ap.id=h.access_point_id
|
||
|
||
JOIN access_point_observations obs
|
||
|
||
ON obs.access_point_id=ap.id
|
||
|
||
WHERE
|
||
|
||
obs.session_id=?
|
||
|
||
GROUP BY
|
||
|
||
h.id
|
||
|
||
ORDER BY
|
||
|
||
h.captured_at DESC
|
||
|
||
""",
|
||
|
||
(
|
||
|
||
session_id,
|
||
|
||
),
|
||
|
||
)
|
||
|
||
if not rows:
|
||
|
||
return ""
|
||
|
||
html = """
|
||
|
||
<table>
|
||
|
||
<tr>
|
||
|
||
<th>BSSID</th>
|
||
|
||
<th>ESSID</th>
|
||
|
||
<th>Type</th>
|
||
|
||
<th>Message Pair</th>
|
||
|
||
<th>Captured</th>
|
||
|
||
</tr>
|
||
|
||
"""
|
||
|
||
for row in rows:
|
||
|
||
(
|
||
|
||
bssid,
|
||
|
||
essid,
|
||
|
||
hs_type,
|
||
|
||
pair,
|
||
|
||
captured,
|
||
|
||
) = row
|
||
|
||
html += f"""
|
||
|
||
<tr>
|
||
|
||
<td>
|
||
|
||
{esc(bssid)}
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
{esc(essid)}
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
{esc(hs_type)}
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
{esc(pair)}
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
{esc(format_datetime(captured))}
|
||
|
||
</td>
|
||
|
||
</tr>
|
||
|
||
"""
|
||
|
||
html += """
|
||
|
||
</table>
|
||
|
||
"""
|
||
|
||
return card(
|
||
|
||
f"🤝 Handshakes ({len(rows)})",
|
||
|
||
html,
|
||
|
||
)
|
||
|
||
# ============================================================
|
||
# Session page
|
||
# ============================================================
|
||
|
||
def render_session_page(
|
||
|
||
conn,
|
||
|
||
session_id,
|
||
|
||
context=None,
|
||
|
||
):
|
||
|
||
if context is None:
|
||
context = ReportContext()
|
||
|
||
context = context.session_page()
|
||
|
||
session = load_session(
|
||
|
||
conn,
|
||
|
||
session_id,
|
||
|
||
)
|
||
|
||
trip = load_trip_information(
|
||
conn,
|
||
session_id,
|
||
)
|
||
|
||
session.update(
|
||
trip
|
||
)
|
||
|
||
track = load_gps_track(
|
||
conn,
|
||
session_id,
|
||
)
|
||
|
||
observations = load_session_observations(
|
||
|
||
conn,
|
||
|
||
session_id,
|
||
|
||
)
|
||
|
||
html = page_begin(
|
||
|
||
f"Session {format_session_folder(Path(session['capture_file']).parent.name)}",
|
||
|
||
active="capture_sessions",
|
||
|
||
context=context,
|
||
|
||
)
|
||
|
||
html += page_header(
|
||
|
||
f"🚗 Session {format_datetime(session.get('trip_start'))}",
|
||
|
||
f"""
|
||
|
||
Trip start:
|
||
|
||
{esc(format_datetime(session.get("trip_start")))}
|
||
|
||
<br>
|
||
|
||
Trip end:
|
||
|
||
{esc(format_datetime(session.get("trip_end")))}
|
||
|
||
<br>
|
||
|
||
Duration:
|
||
|
||
{esc(session.get("duration", "-"))}
|
||
|
||
<br>
|
||
|
||
Database imported:
|
||
|
||
{esc(format_datetime(session["start_time"]))}
|
||
|
||
<br>
|
||
|
||
Session folder:
|
||
|
||
{esc(Path(session["capture_file"]).parent.name)}
|
||
|
||
"""
|
||
|
||
)
|
||
|
||
html += summary_block(
|
||
|
||
conn,
|
||
|
||
session,
|
||
|
||
observations,
|
||
|
||
)
|
||
|
||
html += files_block(
|
||
|
||
session,
|
||
|
||
)
|
||
|
||
html += map_block(
|
||
|
||
track,
|
||
|
||
observations,
|
||
|
||
context,
|
||
|
||
)
|
||
|
||
html += access_points_block(
|
||
|
||
observations,
|
||
|
||
)
|
||
|
||
|
||
# Handshakes block removed
|
||
|
||
|
||
html += credentials_block(
|
||
|
||
conn,
|
||
|
||
session_id,
|
||
|
||
)
|
||
|
||
html += page_end()
|
||
|
||
return html
|
||
|
||
|
||
def save_session_page(
|
||
conn,
|
||
session_id,
|
||
context=None,
|
||
):
|
||
html = render_session_page(
|
||
conn,
|
||
session_id,
|
||
context,
|
||
)
|
||
|
||
output_dir = (
|
||
Path(__file__).parent
|
||
/ "output"
|
||
/ "sessions"
|
||
)
|
||
|
||
output_dir.mkdir(
|
||
parents=True,
|
||
exist_ok=True,
|
||
)
|
||
|
||
filename = (
|
||
output_dir
|
||
/ f"{session_id}.html"
|
||
)
|
||
|
||
filename.write_text(
|
||
html,
|
||
encoding="utf-8",
|
||
)
|
||
|
||
return filename
|
||
|
||
# ============================================================
|
||
# Build all pages
|
||
# ============================================================
|
||
|
||
def build_all_session_pages(
|
||
conn,
|
||
context=None,
|
||
):
|
||
rows = query_all(
|
||
conn,
|
||
"""
|
||
SELECT id
|
||
FROM capture_sessions
|
||
ORDER BY start_time
|
||
""",
|
||
)
|
||
generated = []
|
||
for (
|
||
session_id,
|
||
) in rows:
|
||
generated.append(
|
||
save_session_page(
|
||
conn,
|
||
session_id,
|
||
context,
|
||
)
|
||
)
|
||
return generated
|