Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ All data is collected locally on your machine.
- Click map markers for detailed information
- Open the menu in the upper-left corner to access Insights, network views, and application information
- Open the Daily Activity Report with D
- Click countries in Insights to zoom to the location
- Click countries in Insights to zoom to a country

---

Expand Down Expand Up @@ -217,6 +217,7 @@ Inspect connections that could not be geolocated and therefore do not appear on
| C | Clear cache |
| H | Help |
| A | About |
| Z | Fit Connections |
| ESC | Close window |

---
Expand Down Expand Up @@ -376,3 +377,5 @@ Thanks to @desrod for suggesting a solution for configurable port support.
Thanks to @hugalafutro for suggesting optional SYS_PTRACE support for process visibility on Linux.

Thanks to @mad-tunes for suggesting optional browser launch control when running TapMap as a service, and configurable cache retention to reduce map clutter on busy systems.

Thanks to @forthrin for detailed first-use feedback and suggestions that improved map navigation and overall usability.
88 changes: 62 additions & 26 deletions src/tapmap/app.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Dash application entry point for TapMap.

This module wires together:
This module wires together the main application layers:

- model: runtime state and data collection (snapshot, GeoIP reload, caches)
- geodb: GeoIP database installation, updates, validation, and status
Expand Down Expand Up @@ -60,6 +60,7 @@
ACTION_CACHE_TERMINAL,
ACTION_CLEAR_CACHE,
ACTION_NORMAL_POLL,
ACTION_ZOOM_CONNECTIONS,
decide_poll_action,
)
from tapmap.state.status_cache import StatusCache
Expand Down Expand Up @@ -702,7 +703,6 @@ def poll_model(
snap, cache, sc_store, view, flash = self._handle_clear_cache(status_cache)
return snap, cache, sc_store, view, flash


if decision.action == ACTION_NORMAL_POLL:
snap, cache, sc_store, view, _flash = self._handle_normal_poll(
tick_n, status_cache, ui_cache
Expand Down Expand Up @@ -998,9 +998,11 @@ def _register_map_callbacks(self) -> None:
Output("map", "figure"),
Input("ui_view", "data"),
Input("selected_country", "data"),
Input("camera_mode", "data"),
)
def render_map(ui_view: Any, selected_country: Any) -> Any:
def render_map(ui_view: Any, selected_country: Any, camera_mode: Any) -> Any:
view = self._ensure_dict(ui_view)
fit_connections = (camera_mode == "connections")

if "points" not in view:
return no_update
Expand All @@ -1013,16 +1015,20 @@ def render_map(ui_view: Any, selected_country: Any) -> Any:
([], self.my_location),
summaries=summaries,
selected_country=selected_country,
fit_connections=fit_connections,
)

return self.ui.create_figure(
(points, self.my_location),
summaries=summaries,
selected_country=selected_country,
fit_connections=fit_connections,
)

@self.app.callback(
Output("selected_country", "data"),
Output("camera_mode", "data"),
Input("key_action", "data"),
Input(
{
"type": "insights-country",
Expand All @@ -1032,31 +1038,53 @@ def render_map(ui_view: Any, selected_country: Any) -> Any:
"n_clicks",
),
State("selected_country", "data"),
State("camera_mode", "data"),
prevent_initial_call=True,
)
def select_insight(_clicks_country: list[int], current: Any) -> Any:
def update_map_state(
key_action: Any,
_clicks_country: list[int],
current_country: Any,
current_camera_mode: Any,
) -> tuple[Any, Any]:

trigger_id = ctx.triggered_id

# Keyboard
if trigger_id == "key_action":
if (
isinstance(key_action, dict)
and key_action.get("action") == ACTION_ZOOM_CONNECTIONS
):
if current_camera_mode == "connections":
return None, None

return None, "connections"

return no_update, no_update

# Country selection
current = current_country if isinstance(current_country, str) else None

if not ctx.triggered:
return no_update
return no_update, no_update

trigger = ctx.triggered[0]

if not trigger["value"]:
return no_update

trigger_id = ctx.triggered_id
return no_update, no_update

if not isinstance(trigger_id, dict):
return no_update
return no_update, no_update

country_code = trigger_id.get("country_code")
if not isinstance(country_code, str):
return no_update
return no_update, no_update

if current == country_code:
return None

return country_code
return None, None

return country_code, None

def _register_status_callbacks(self) -> None:
@self.app.callback(
Output("status_bar", "children"),
Expand All @@ -1081,12 +1109,17 @@ def _register_insights_callbacks(self) -> None:
@self.app.callback(
Output("insights_panel", "children"),
Input("insights_cache", "data"),
Input("selected_country", "data"),
)
def update_insights(data: dict[str, Any] | None) -> list[Any]:
def update_insights(
data: dict[str, Any] | None,
selected_country: Any,
) -> list[Any]:

if not isinstance(data, dict):
return []

return render_insights_panel(data)
return render_insights_panel(data, selected_country=selected_country)

@self.app.callback(
Output("insights_cache", "data"),
Expand Down Expand Up @@ -1137,8 +1170,8 @@ def _maybe_save_insights(self) -> None:
now = datetime.now().timestamp()

if now - self._last_insights_save >= 60:
self._save_insights()
self._last_insights_save = now
self._save_insights()

def _save_insights(self) -> None:
"""Write insights data to disk atomically (delegated)."""
Expand All @@ -1158,19 +1191,22 @@ def _acquire_lock(self) -> None:
try:
lock = json.loads(self._lock_path.read_text(encoding="utf-8"))

running_process = psutil.Process(int(lock["pid"]))
if (
isinstance(lock, dict)
and "pid" in lock
and "create_time" in lock
):
running_process = psutil.Process(int(lock["pid"]))

if running_process.create_time() == float(lock["create_time"]):
self.logger.warning(
"Another TapMap instance is already running (PID %d). Exiting.",
running_process.pid,
)
sys.exit(1)
if running_process.create_time() == float(lock["create_time"]):
self.logger.warning(
"Another TapMap instance is already running (PID %d). Exiting.",
running_process.pid,
)
sys.exit(1)

except (
OSError,
ValueError,
KeyError,
json.JSONDecodeError,
psutil.NoSuchProcess,
):
Expand Down
3 changes: 2 additions & 1 deletion src/tapmap/assets/keyboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"c",
"h",
"a",
"z",
]);

function isTypingTarget(el) {
Expand Down Expand Up @@ -66,7 +67,7 @@
setter.call(el, value);
}

function sendToken(token) {
window.sendToken = function (token) {
const inp = document.getElementById("key_capture");

if (!inp) return;
Expand Down
45 changes: 45 additions & 0 deletions src/tapmap/assets/modebar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/* Plotly modebar custom buttons and actions. */

// Dash creates the Plotly modebar after page load.
// Wait until it exists before inserting the custom button.
const observer = new MutationObserver(() => {
const groups = document.querySelectorAll(".modebar-group");

if (groups.length < 3) {
return;
}

// Already added.
if (document.querySelector('[aria-label="Fit Connections"]')) {
observer.disconnect();
return;
}

const fitButton = groups[2].lastElementChild.cloneNode(true);

fitButton.setAttribute("data-title", "Fit Connections");
fitButton.setAttribute("aria-label", "Fit Connections");

// Replace the Reset icon.
fitButton.querySelector("svg").outerHTML = `
<svg class="icon" viewBox="0 0 24 24" height="1em" width="1em">
<path
d="M3 9V3h6v2H5v4H3zm16 0V5h-4V3h6v6h-2zM3 15h2v4h4v2H3v-6zm16 4v-4h2v6h-6v-2h4z"
style="fill: rgba(255,255,255,0.3);">
</path>
</svg>`;

fitButton.addEventListener("click", () => {
window.sendToken("__z__");
});

// Insert before Zoom In.
groups[2].insertBefore(fitButton, groups[2].firstChild);

observer.disconnect();
});

observer.observe(document.body, {
childList: true,
subtree: true,
});
7 changes: 7 additions & 0 deletions src/tapmap/assets/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,13 @@ summary.mx-acc-header:hover {
opacity: 0.8;
}

.insights-row.selected {
border-left: 4px solid var(--mx-green);
padding-left: 6px;
color: white;
font-weight: 700;
}

/* Cells */
.insights-flag {
flex: 0 0 21px;
Expand Down
24 changes: 21 additions & 3 deletions src/tapmap/insights_persistence.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
"""Persistence and orchestration helpers for insights data."""

import json
import logging
import time
from pathlib import Path
from typing import Any

from tapmap.state.daily_report import DailyReportData, build_report_data

logger = logging.getLogger(__name__)


def load_insights(path: Path) -> dict[str, Any]:
"""Load insights from a JSON file, restoring historical contract.
Expand All @@ -27,8 +31,7 @@ def load_insights(path: Path) -> dict[str, Any]:
if not isinstance(insights, dict):
raise ValueError("insights is not a dict")
normalized = {
k: dict(insights[k]) if isinstance(insights.get(k), dict) else {}
for k in expected_keys
k: dict(insights[k]) if isinstance(insights.get(k), dict) else {} for k in expected_keys
}
return normalized
except Exception:
Expand Down Expand Up @@ -56,7 +59,22 @@ def save_insights(path: Path, data: dict[str, Any]) -> None:
)
f.flush()

tmp_path.replace(path)
# The temporary file has been closed. Retry the atomic replace in
# case another process briefly locks the destination file.
for attempt in range(6):
try:
tmp_path.replace(path)
return

except OSError:
if attempt == 5:
raise

logger.info(
"insights.json temporarily locked; retrying in 1 second (%d/5)...",
attempt + 1,
)
time.sleep(1)


def build_daily_report(insights: dict[str, Any]) -> DailyReportData:
Expand Down
2 changes: 1 addition & 1 deletion src/tapmap/state/daily_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ def _build_country_map_points(
country_state: dict[str, dict[str, Any]],
) -> list[CountryMapPoint]:
"""Return map point data for all observed countries with known centroids."""
from tapmap.ui.country_centers import get_center # deferred to avoid import cycle
from tapmap.ui.country_info import get_center # deferred to avoid import cycle

points: list[CountryMapPoint] = []
for code, item in country_state.items():
Expand Down
1 change: 1 addition & 0 deletions src/tapmap/state/keyboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"__c__": "menu_clear_cache",
"__h__": "menu_help",
"__a__": "menu_about",
"__z__": "zoom_connections",
"__esc__": "escape",
}

Expand Down
4 changes: 4 additions & 0 deletions src/tapmap/state/poll.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
ACTION_CLEAR_CACHE = "clear_cache"
ACTION_CACHE_TERMINAL = "cache_terminal"
ACTION_NORMAL_POLL = "normal_poll"
ACTION_ZOOM_CONNECTIONS = "zoom_connections"

@dataclass(frozen=True)
class PollDecision:
"""Describe which poll action to execute."""
Expand Down Expand Up @@ -48,5 +50,7 @@ def decide_poll_action(*, trigger: Any, key_action: Any) -> PollDecision:
return PollDecision(action=ACTION_CLEAR_CACHE)
if action == "menu_cache_terminal":
return PollDecision(action=ACTION_CACHE_TERMINAL)
if action == "zoom_connections":
return PollDecision(action=ACTION_ZOOM_CONNECTIONS)

return PollDecision(action=ACTION_NORMAL_POLL)
Loading
Loading