mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-10 02:42:45 +02:00
Merge pull request #321 from ipnet-mesh/feat/routes-max-path-length
feat(routes): add per-route max_path_length cap
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
"""add routes.max_path_length
|
||||
|
||||
Revision ID: d307ee761a34
|
||||
Revises: 5e3b712ccf10
|
||||
Create Date: 2026-07-20 12:00:00.000000+00:00
|
||||
|
||||
Adds a single nullable ``max_path_length`` column to ``routes``. The
|
||||
new knob caps the total number of hops in a candidate packet's full
|
||||
path; receptions whose path exceeds it are dropped from matching
|
||||
consideration entirely (before the subsequence matcher runs), so
|
||||
over-long paths never count toward ``packet_count_threshold``. Complements
|
||||
the existing ``max_hop_span`` (which only constrains the gap between
|
||||
the first and last *matched* configured node).
|
||||
|
||||
``null`` (the default) means unlimited and preserves the previous
|
||||
behaviour for every existing route, so this migration is additive and
|
||||
backwards-compatible with no data backfill.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "d307ee761a34"
|
||||
down_revision: Union[str, None] = "5e3b712ccf10"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"routes",
|
||||
sa.Column("max_path_length", sa.Integer(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("routes", "max_path_length")
|
||||
@@ -21,6 +21,7 @@ Each route carries these knobs:
|
||||
| `packet_count_threshold` | `5` | Distinct matching packets at/above which the route is `healthy`. "Distinct" is per underlying event, not per transmission — see [How health is evaluated](#how-health-is-evaluated) above. |
|
||||
| `clear_threshold` | _(3× threshold)_ | Comfort bar for the `clear`/`marginal` split. Omit/null to use three times the threshold. |
|
||||
| `max_hop_span` | `8` | Caps the position gap between the first and last matched node, to reject matches that wander too far. |
|
||||
| `max_path_length` | _(unlimited)_ | Caps the total number of hops in a candidate packet's full path; receptions whose path exceeds this are dropped from matching entirely (never counted toward `packet_count_threshold`). Useful to ignore wandering packets that happen to include the configured endpoints but traversed a long detour. |
|
||||
| `reversible` | `true` | Also match the path in reverse direction. |
|
||||
| `enabled` | `true` | When `false`, the route is skipped by the evaluator and reports `unknown`/`no_coverage`. |
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@ routes:
|
||||
packet_count_threshold: 5
|
||||
# clear_threshold: 15 # optional; omit/null = 3x threshold
|
||||
# max_hop_span: 8 # optional; omit/null = unlimited
|
||||
# max_path_length: 16 # optional; omit/null = unlimited (drops over-long packet paths)
|
||||
enabled: true
|
||||
reversible: true # match both directions (A->B and B->A)
|
||||
path:
|
||||
|
||||
@@ -21,6 +21,7 @@ routes:
|
||||
packet_count_threshold: 3
|
||||
# clear_threshold: 10 # optional; omit/null = 2x threshold
|
||||
# max_hop_span: 8 # optional; omit/null = unlimited
|
||||
# max_path_length: 16 # optional; omit/null = unlimited (drops over-long packet paths)
|
||||
enabled: true
|
||||
reversible: true # match both directions (A->B and B->A)
|
||||
path:
|
||||
|
||||
@@ -116,6 +116,7 @@ def _route_to_read(route: Route, *, quality_avg: Any = _UNSET) -> RouteRead:
|
||||
packet_count_threshold=route.packet_count_threshold,
|
||||
clear_threshold=route.clear_threshold,
|
||||
max_hop_span=route.max_hop_span,
|
||||
max_path_length=route.max_path_length,
|
||||
enabled=route.enabled,
|
||||
reversible=route.reversible,
|
||||
route_nodes=[_route_node_to_read(rn) for rn in route.route_nodes],
|
||||
@@ -269,6 +270,7 @@ def create_route(
|
||||
packet_count_threshold=body.packet_count_threshold,
|
||||
clear_threshold=body.clear_threshold,
|
||||
max_hop_span=body.max_hop_span,
|
||||
max_path_length=body.max_path_length,
|
||||
enabled=body.enabled,
|
||||
reversible=body.reversible,
|
||||
)
|
||||
@@ -573,6 +575,8 @@ def update_route(
|
||||
route.clear_threshold = body.clear_threshold
|
||||
if body.max_hop_span is not None:
|
||||
route.max_hop_span = body.max_hop_span
|
||||
if body.max_path_length is not None:
|
||||
route.max_path_length = body.max_path_length
|
||||
if body.enabled is not None:
|
||||
route.enabled = body.enabled
|
||||
if body.reversible is not None:
|
||||
@@ -646,6 +650,7 @@ def preview(
|
||||
"match_width": body.match_width,
|
||||
"observer_ids": [n.id for n in observer_nodes] if observer_nodes else None,
|
||||
"max_hop_span": body.max_hop_span,
|
||||
"max_path_length": body.max_path_length,
|
||||
"packet_count_threshold": body.packet_count_threshold,
|
||||
"clear_threshold": body.clear_threshold,
|
||||
"reversible": body.reversible,
|
||||
|
||||
@@ -810,6 +810,7 @@ def _import_routes(
|
||||
)
|
||||
route.clear_threshold = value.get("clear_threshold")
|
||||
route.max_hop_span = value.get("max_hop_span", 8)
|
||||
route.max_path_length = value.get("max_path_length")
|
||||
route.enabled = value.get("enabled", True)
|
||||
route.reversible = value.get("reversible", True)
|
||||
# Replace path nodes wholesale
|
||||
@@ -830,6 +831,7 @@ def _import_routes(
|
||||
packet_count_threshold=value.get("packet_count_threshold", 5),
|
||||
clear_threshold=value.get("clear_threshold"),
|
||||
max_hop_span=value.get("max_hop_span", 8),
|
||||
max_path_length=value.get("max_path_length"),
|
||||
enabled=value.get("enabled", True),
|
||||
reversible=value.get("reversible", True),
|
||||
)
|
||||
|
||||
@@ -97,6 +97,7 @@ def _subsequence_indices(
|
||||
path: list[dict[str, Any]],
|
||||
expected: list[str],
|
||||
max_hop_span: Optional[int] = None,
|
||||
max_path_length: Optional[int] = None,
|
||||
) -> Optional[tuple[int, int]]:
|
||||
"""Two-pointer subsequence prefix match with gaps allowed.
|
||||
|
||||
@@ -104,10 +105,15 @@ def _subsequence_indices(
|
||||
*expected* is the ordered list of uppercase hash prefixes to find.
|
||||
A hop matches when ``node_hash.startswith(expected_hash)``.
|
||||
``max_hop_span`` constrains ``position(last) - position(first)`` when set.
|
||||
``max_path_length`` caps the total number of hops in *path*; when set
|
||||
and ``len(path)`` exceeds it, the packet is rejected up front (returns
|
||||
``None``) without running the match.
|
||||
|
||||
Returns the ``(first_i, last_i)`` indices into *path* of the matched
|
||||
endpoints, or ``None`` when no match is found.
|
||||
"""
|
||||
if max_path_length is not None and len(path) > max_path_length:
|
||||
return None
|
||||
if not expected:
|
||||
return None
|
||||
pi = 0
|
||||
@@ -144,6 +150,7 @@ def is_subsequence(
|
||||
path: list[dict[str, Any]],
|
||||
expected: list[str],
|
||||
max_hop_span: Optional[int] = None,
|
||||
max_path_length: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Pure two-pointer subsequence prefix match with gaps allowed.
|
||||
|
||||
@@ -151,14 +158,18 @@ def is_subsequence(
|
||||
*expected* is the ordered list of uppercase hash prefixes to find.
|
||||
A hop matches when ``node_hash.startswith(expected_hash)``.
|
||||
``max_hop_span`` constrains ``position(last) - position(first)`` when set.
|
||||
``max_path_length`` caps the total number of hops in *path* when set.
|
||||
"""
|
||||
return _subsequence_indices(path, expected, max_hop_span) is not None
|
||||
return (
|
||||
_subsequence_indices(path, expected, max_hop_span, max_path_length) is not None
|
||||
)
|
||||
|
||||
|
||||
def _matched_subpath(
|
||||
hops: list[dict[str, Any]],
|
||||
expected: list[str],
|
||||
max_hop_span: Optional[int] = None,
|
||||
max_path_length: Optional[int] = None,
|
||||
reversible: bool = True,
|
||||
) -> Optional[list[dict[str, Any]]]:
|
||||
"""Return the slice of *hops* between the first and last matched node.
|
||||
@@ -169,7 +180,7 @@ def _matched_subpath(
|
||||
(never reversed), so a reverse-direction packet shows as To -> ... -> From.
|
||||
"""
|
||||
subpath, _first, _last = _matched_subpath_with_indices(
|
||||
hops, expected, max_hop_span, reversible
|
||||
hops, expected, max_hop_span, max_path_length, reversible
|
||||
)
|
||||
return subpath
|
||||
|
||||
@@ -178,6 +189,7 @@ def _matched_subpath_with_indices(
|
||||
hops: list[dict[str, Any]],
|
||||
expected: list[str],
|
||||
max_hop_span: Optional[int] = None,
|
||||
max_path_length: Optional[int] = None,
|
||||
reversible: bool = True,
|
||||
) -> tuple[Optional[list[dict[str, Any]]], Optional[int], Optional[int]]:
|
||||
"""Variant of :func:`_matched_subpath` that also returns match indices.
|
||||
@@ -189,11 +201,13 @@ def _matched_subpath_with_indices(
|
||||
``route_recent_matches`` so the detail page can slice the live hop
|
||||
list without re-running the matcher.
|
||||
"""
|
||||
idx = _subsequence_indices(hops, expected, max_hop_span)
|
||||
idx = _subsequence_indices(hops, expected, max_hop_span, max_path_length)
|
||||
if idx is not None:
|
||||
return hops[idx[0] : idx[1] + 1], idx[0], idx[1]
|
||||
if reversible and len(expected) > 1:
|
||||
idx = _subsequence_indices(hops, list(reversed(expected)), max_hop_span)
|
||||
idx = _subsequence_indices(
|
||||
hops, list(reversed(expected)), max_hop_span, max_path_length
|
||||
)
|
||||
if idx is not None:
|
||||
return hops[idx[0] : idx[1] + 1], idx[0], idx[1]
|
||||
return None, None, None
|
||||
@@ -203,10 +217,14 @@ def _match_hops(
|
||||
hops: list[dict[str, Any]],
|
||||
expected: list[str],
|
||||
max_hop_span: Optional[int] = None,
|
||||
max_path_length: Optional[int] = None,
|
||||
reversible: bool = True,
|
||||
) -> bool:
|
||||
"""Check whether *hops* match *expected* forward (and optionally reverse)."""
|
||||
return _matched_subpath(hops, expected, max_hop_span, reversible) is not None
|
||||
return (
|
||||
_matched_subpath(hops, expected, max_hop_span, max_path_length, reversible)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _fetch_candidate_paths_maybe_bidirectional(
|
||||
@@ -518,7 +536,9 @@ def evaluate_route(
|
||||
|
||||
matched_packets: set[str] = set()
|
||||
for hops in paths.values():
|
||||
if _match_hops(hops, expected, route.max_hop_span, reversible):
|
||||
if _match_hops(
|
||||
hops, expected, route.max_hop_span, route.max_path_length, reversible
|
||||
):
|
||||
identity = _match_identity(hops)
|
||||
if identity:
|
||||
matched_packets.add(identity)
|
||||
@@ -571,7 +591,9 @@ def evaluate_route_day(
|
||||
|
||||
matched_packets: set[str] = set()
|
||||
for hops in paths.values():
|
||||
if _match_hops(hops, expected, route.max_hop_span, reversible):
|
||||
if _match_hops(
|
||||
hops, expected, route.max_hop_span, route.max_path_length, reversible
|
||||
):
|
||||
identity = _match_identity(hops)
|
||||
if identity:
|
||||
matched_packets.add(identity)
|
||||
@@ -732,7 +754,9 @@ def evaluate_route_history(
|
||||
for i in range(historical_days):
|
||||
matched_packets: set[str] = set()
|
||||
for hops in day_paths[i].values():
|
||||
if _match_hops(hops, expected, route.max_hop_span, reversible):
|
||||
if _match_hops(
|
||||
hops, expected, route.max_hop_span, route.max_path_length, reversible
|
||||
):
|
||||
identity = _match_identity(hops)
|
||||
if identity:
|
||||
matched_packets.add(identity)
|
||||
@@ -1129,7 +1153,11 @@ def recent_matches(
|
||||
matches_by_identity: dict[str, dict[str, Any]] = {}
|
||||
for rp_id, hops in paths.items():
|
||||
subpath, first_idx, last_idx = _matched_subpath_with_indices(
|
||||
hops, expected, route.max_hop_span, reversible
|
||||
hops,
|
||||
expected,
|
||||
route.max_hop_span,
|
||||
route.max_path_length,
|
||||
reversible,
|
||||
)
|
||||
if not subpath or first_idx is None or last_idx is None:
|
||||
continue
|
||||
@@ -1232,13 +1260,14 @@ def preview_route(
|
||||
"""Preview matching for an unsaved route config.
|
||||
|
||||
*config* keys: ``node_ids``, ``match_width``, ``observer_ids``,
|
||||
``max_hop_span``, ``packet_count_threshold``, ``clear_threshold``,
|
||||
``reversible``.
|
||||
``max_hop_span``, ``max_path_length``, ``packet_count_threshold``,
|
||||
``clear_threshold``, ``reversible``.
|
||||
"""
|
||||
node_ids: list[str] = config.get("node_ids") or []
|
||||
match_width: int = config.get("match_width") or 1
|
||||
observer_ids: Optional[list[str]] = config.get("observer_ids") or None
|
||||
max_hop_span: Optional[int] = config.get("max_hop_span")
|
||||
max_path_length: Optional[int] = config.get("max_path_length")
|
||||
threshold: int = config.get("packet_count_threshold") or 5
|
||||
clear_bar: Optional[int] = config.get("clear_threshold")
|
||||
reversible: bool = config.get("reversible", True)
|
||||
@@ -1298,7 +1327,7 @@ def preview_route(
|
||||
contributing: dict[str, int] = {}
|
||||
|
||||
for hops in paths.values():
|
||||
if _match_hops(hops, expected, max_hop_span, reversible):
|
||||
if _match_hops(hops, expected, max_hop_span, max_path_length, reversible):
|
||||
identity = _match_identity(hops)
|
||||
if identity:
|
||||
matched_packets.add(identity)
|
||||
|
||||
@@ -43,6 +43,7 @@ class Route(Base, UUIDMixin, TimestampMixin):
|
||||
packet_count_threshold: Minimum distinct packets for healthy
|
||||
clear_threshold: Comfort bar for the clear/marginal split (null = 3x threshold)
|
||||
max_hop_span: Max hops between first and last configured node (null = unlimited)
|
||||
max_path_length: Max number of hops in a candidate packet's full path (null = unlimited)
|
||||
enabled: Whether this route is actively evaluated
|
||||
"""
|
||||
|
||||
@@ -86,6 +87,11 @@ class Route(Base, UUIDMixin, TimestampMixin):
|
||||
default=8,
|
||||
nullable=True,
|
||||
)
|
||||
max_path_length: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
default=None,
|
||||
nullable=True,
|
||||
)
|
||||
enabled: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=True,
|
||||
|
||||
@@ -75,6 +75,11 @@ class RouteCreate(BaseModel):
|
||||
max_hop_span: Optional[int] = Field(
|
||||
default=8, description="Max hop distance between first and last node"
|
||||
)
|
||||
max_path_length: Optional[int] = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
description="Max hops in a candidate packet's full path; null = unlimited",
|
||||
)
|
||||
enabled: bool = Field(default=True, description="Whether this route is evaluated")
|
||||
reversible: bool = Field(
|
||||
default=True, description="Whether to match the path in both directions"
|
||||
@@ -113,6 +118,7 @@ class RouteUpdate(BaseModel):
|
||||
packet_count_threshold: Optional[int] = Field(default=None, ge=1, le=10000)
|
||||
clear_threshold: Optional[int] = None
|
||||
max_hop_span: Optional[int] = None
|
||||
max_path_length: Optional[int] = Field(default=None, ge=1)
|
||||
enabled: Optional[bool] = None
|
||||
reversible: Optional[bool] = None
|
||||
node_public_keys: Optional[list[str]] = None
|
||||
@@ -149,6 +155,7 @@ class RouteRead(BaseModel):
|
||||
packet_count_threshold: int
|
||||
clear_threshold: Optional[int] = None
|
||||
max_hop_span: Optional[int] = None
|
||||
max_path_length: Optional[int] = None
|
||||
enabled: bool
|
||||
reversible: bool
|
||||
route_nodes: list[RouteNodeRead] = []
|
||||
@@ -205,6 +212,7 @@ class RouteDetail(BaseModel):
|
||||
packet_count_threshold: int
|
||||
clear_threshold: Optional[int] = None
|
||||
max_hop_span: Optional[int] = None
|
||||
max_path_length: Optional[int] = None
|
||||
enabled: bool
|
||||
reversible: bool
|
||||
route_nodes: list[RouteNodeRead] = []
|
||||
@@ -236,6 +244,7 @@ class RoutePreviewRequest(BaseModel):
|
||||
packet_count_threshold: int = Field(default=5, ge=1, le=10000)
|
||||
clear_threshold: Optional[int] = None
|
||||
max_hop_span: Optional[int] = Field(default=8)
|
||||
max_path_length: Optional[int] = Field(default=None, ge=1)
|
||||
observer_public_keys: Optional[list[str]] = None
|
||||
reversible: bool = Field(default=True)
|
||||
|
||||
|
||||
@@ -189,3 +189,11 @@ export function iconRouteFrom(cls = 'h-5 w-5') {
|
||||
export function iconRouteTo(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12h13.5m0 0l-4-4m4 4l-4 4" /><circle cx="19" cy="12" r="2.5" stroke-width="2" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconHopSpan(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 12h6M14 12h6M4 12l3-3M4 12l3 3M20 12l-3-3M20 12l-3 3" /><circle cx="12" cy="12" r="2" stroke-width="2" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconPathLength(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5v14M19 5v14M9 12h6M9 12l3-3M9 12l3 3M15 12l-3-3M15 12l-3 3" /></svg>`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiGet, apiPost, apiPut, apiDelete, isAbortError } from '../api.js';
|
||||
import { html, litRender, nothing, t, errorAlert, getConfig, hasRole } from '../components.js';
|
||||
import { iconPath, iconPlus, iconEdit, iconTrash, iconPackets, iconClock, iconRuler, iconNodes, iconSatelliteDish, iconRouteFrom, iconRouteTo } from '../icons.js';
|
||||
import { iconPath, iconPlus, iconEdit, iconTrash, iconPackets, iconClock, iconRuler, iconNodes, iconSatelliteDish, iconRouteFrom, iconRouteTo, iconHopSpan, iconPathLength } from '../icons.js';
|
||||
|
||||
const VISIBILITY_ORDER = ['community', 'member', 'operator', 'admin'];
|
||||
|
||||
@@ -91,27 +91,31 @@ function renderStatsRow(route) {
|
||||
const obsCount = (route.route_observers || []).length;
|
||||
|
||||
return html`<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs opacity-60 mt-1">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-flex items-center gap-1 tooltip tooltip-top" data-tip=${t('routes.stats_matched_tip')}>
|
||||
${iconPackets('h-3.5 w-3.5')}
|
||||
<span>${matched}/${threshold}\u2192${degraded}</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-flex items-center gap-1 tooltip tooltip-top" data-tip=${t('routes.stats_window_tip')}>
|
||||
${iconClock('h-3.5 w-3.5')}
|
||||
<span>${route.window_hours}h</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-flex items-center gap-1 tooltip tooltip-top" data-tip=${t('routes.stats_width_tip')}>
|
||||
${iconRuler('h-3.5 w-3.5')}
|
||||
<span>${route.match_width}B</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-flex items-center gap-1 tooltip tooltip-top" data-tip=${t('routes.stats_nodes_tip')}>
|
||||
${iconNodes('h-3.5 w-3.5')}
|
||||
<span>${nodeCount}</span>
|
||||
</span>
|
||||
${route.max_hop_span ? html`<span class="inline-flex items-center gap-1">
|
||||
${iconPath('h-3.5 w-3.5')}
|
||||
<span>${route.max_hop_span}</span>
|
||||
</span>` : nothing}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-flex items-center gap-1 tooltip tooltip-top" data-tip=${t('routes.stats_span_tip')}>
|
||||
${iconHopSpan('h-3.5 w-3.5')}
|
||||
<span>${route.max_hop_span || '\u221E'}</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 tooltip tooltip-top" data-tip=${t('routes.stats_length_tip')}>
|
||||
${iconPathLength('h-3.5 w-3.5')}
|
||||
<span>${route.max_path_length || '\u221E'}</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 tooltip tooltip-top" data-tip=${t('routes.stats_observers_tip')}>
|
||||
${iconSatelliteDish('h-3.5 w-3.5')}
|
||||
<span>${obsCount || '\u221E'}</span>
|
||||
</span>
|
||||
@@ -419,6 +423,12 @@ function renderRouteModal({ modalState, onSave, onCancel, saving }) {
|
||||
.value=${route.max_hop_span || ''}
|
||||
placeholder="\u221E" min="1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm opacity-70">${t('routes.path_length_label')}</label>
|
||||
<input type="number" id="route-modal-path-length" class="input input-sm w-full"
|
||||
.value=${route.max_path_length || ''}
|
||||
placeholder="\u221E" min="1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-6">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
@@ -785,6 +795,7 @@ export async function render(container, params, router) {
|
||||
const thresholdEl = document.getElementById('route-modal-threshold');
|
||||
const clearEl = document.getElementById('route-modal-clear');
|
||||
const spanEl = document.getElementById('route-modal-span');
|
||||
const pathLengthEl = document.getElementById('route-modal-path-length');
|
||||
const enabledEl = document.getElementById('route-modal-enabled');
|
||||
const reversibleEl = document.getElementById('route-modal-reversible');
|
||||
|
||||
@@ -806,6 +817,7 @@ export async function render(container, params, router) {
|
||||
window_hours: parseInt(windowEl.value, 10) || 48,
|
||||
packet_count_threshold: parseInt(thresholdEl.value, 10) || 5,
|
||||
max_hop_span: spanEl.value ? parseInt(spanEl.value, 10) : null,
|
||||
max_path_length: pathLengthEl.value ? parseInt(pathLengthEl.value, 10) : null,
|
||||
enabled: enabledEl.checked,
|
||||
reversible: reversibleEl.checked,
|
||||
node_public_keys: nodePublicKeys,
|
||||
|
||||
@@ -339,6 +339,14 @@
|
||||
"threshold_label": "Threshold",
|
||||
"clear_label": "Clear Threshold",
|
||||
"span_label": "Max Span",
|
||||
"path_length_label": "Max Path Length",
|
||||
"stats_matched_tip": "Matched / threshold \u2192 clear bar",
|
||||
"stats_window_tip": "Lookback window (hours)",
|
||||
"stats_width_tip": "Hash prefix width (bytes)",
|
||||
"stats_nodes_tip": "Configured path nodes",
|
||||
"stats_span_tip": "Max gap between matched endpoints (\u221E = unlimited)",
|
||||
"stats_length_tip": "Max hops in packet path (\u221E = unlimited)",
|
||||
"stats_observers_tip": "Observer allow-list size (\u221E = all)",
|
||||
"enabled_label": "Enabled",
|
||||
"reversible_label": "Reversible (match both directions)",
|
||||
"disabled": "Disabled",
|
||||
|
||||
@@ -261,6 +261,14 @@
|
||||
"threshold_label": "Drempel",
|
||||
"clear_label": "Heldere drempel",
|
||||
"span_label": "Max span",
|
||||
"path_length_label": "Max padlengte",
|
||||
"stats_matched_tip": "Matched / drempel \u2192 helder",
|
||||
"stats_window_tip": "Lookback venster (uren)",
|
||||
"stats_width_tip": "Hash-prefixbreedte (bytes)",
|
||||
"stats_nodes_tip": "Geconfigureerde pad-knooppunten",
|
||||
"stats_span_tip": "Max afstand tussen matched eindpunten (\u221E = onbeperkt)",
|
||||
"stats_length_tip": "Max hops in pakketpad (\u221E = onbeperkt)",
|
||||
"stats_observers_tip": "Observer-allow-list (\u221E = alle)",
|
||||
"enabled_label": "Ingeschakeld",
|
||||
"reversible_label": "Omkeerbaar (beide richtingen)",
|
||||
"disabled": "Uitgeschakeld",
|
||||
|
||||
@@ -950,6 +950,7 @@ class TestUpdateRouteFields:
|
||||
"packet_count_threshold": 5,
|
||||
"clear_threshold": 8,
|
||||
"max_hop_span": 4,
|
||||
"max_path_length": 6,
|
||||
"enabled": False,
|
||||
"reversible": False,
|
||||
},
|
||||
@@ -964,6 +965,7 @@ class TestUpdateRouteFields:
|
||||
assert data["packet_count_threshold"] == 5
|
||||
assert data["clear_threshold"] == 8
|
||||
assert data["max_hop_span"] == 4
|
||||
assert data["max_path_length"] == 6
|
||||
assert data["enabled"] is False
|
||||
assert data["reversible"] is False
|
||||
|
||||
|
||||
@@ -499,6 +499,7 @@ class TestImportRoutes:
|
||||
" packet_count_threshold: 5\n"
|
||||
" clear_threshold: 8\n"
|
||||
" max_hop_span: 3\n"
|
||||
" max_path_length: 12\n"
|
||||
" enabled: true\n"
|
||||
" reversible: false\n"
|
||||
f" observers:\n - '{_PK_C}'\n"
|
||||
@@ -519,6 +520,7 @@ class TestImportRoutes:
|
||||
assert route.packet_count_threshold == 5
|
||||
assert route.clear_threshold == 8
|
||||
assert route.max_hop_span == 3
|
||||
assert route.max_path_length == 12
|
||||
assert route.enabled is True
|
||||
assert route.reversible is False
|
||||
assert route.description == "a route"
|
||||
|
||||
@@ -172,6 +172,31 @@ class TestIsSubsequence:
|
||||
]
|
||||
assert is_subsequence(path, ["A1", "B2"], max_hop_span=2) is False
|
||||
|
||||
def test_path_length_cap_within(self):
|
||||
path = [
|
||||
{"position": 0, "node_hash": "A1"},
|
||||
{"position": 1, "node_hash": "X"},
|
||||
{"position": 2, "node_hash": "B2"},
|
||||
]
|
||||
assert is_subsequence(path, ["A1", "B2"], max_path_length=3) is True
|
||||
|
||||
def test_path_length_cap_exceeds(self):
|
||||
path = [
|
||||
{"position": 0, "node_hash": "A1"},
|
||||
{"position": 1, "node_hash": "X"},
|
||||
{"position": 2, "node_hash": "X"},
|
||||
{"position": 3, "node_hash": "X"},
|
||||
{"position": 4, "node_hash": "B2"},
|
||||
]
|
||||
assert is_subsequence(path, ["A1", "B2"], max_path_length=3) is False
|
||||
|
||||
def test_path_length_cap_zero_ignored(self):
|
||||
"""max_path_length=None means unlimited (default)."""
|
||||
path = [{"position": i, "node_hash": "X"} for i in range(20)]
|
||||
path[0] = {"position": 0, "node_hash": "A1"}
|
||||
path[-1] = {"position": 19, "node_hash": "B2"}
|
||||
assert is_subsequence(path, ["A1", "B2"]) is True
|
||||
|
||||
def test_empty_expected(self):
|
||||
assert is_subsequence([{"position": 0, "node_hash": "A1"}], []) is False
|
||||
|
||||
|
||||
Reference in New Issue
Block a user