Files
2026-10-06 21:08:12 +03:00

2712 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"])}
&nbsp;&nbsp;
Frequency:
{esc(ap["frequency"])}
<br>
RSSI:
{esc(ap["rssi"])} dBm
<br>
Handshake:
{"✅" if ap["handshake"] else "—"}
&nbsp;&nbsp;
PMKID:
{"✅" if ap["pmkid"] else "—"}
&nbsp;&nbsp;
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