feat: integrate visualiser-wasm build and testing into the project, improve network visualization capabilities and add battery saver

This commit is contained in:
Ivan 2026-07-16 10:19:48 -05:00
parent d801d095de
commit fb08e76be2
No known key found for this signature in database
65 changed files with 6930 additions and 694 deletions

8
.gitignore vendored
View file

@ -142,6 +142,14 @@ meshchat-config/
meshchatx/src/frontend/public/vendor/micron-parser-go/micron-parser-go.wasm
meshchatx/src/frontend/public/vendor/micron-parser-go/wasm_exec.js
# visualiser-wasm (built via scripts/build-visualiser-wasm.mjs / task build:visualiser-wasm)
meshchatx/src/frontend/public/vendor/visualiser-wasm/visualiser.wasm
meshchatx/src/frontend/public/vendor/visualiser-wasm/wasm_exec.js
.gocache/
.gotmp/
.vitest-cache/
.hypothesis
.hypothesis/

View file

@ -390,10 +390,67 @@ tasks:
build:frontend:
aliases: [build:fe]
desc: Build frontend assets
deps: [deps:frontend]
deps: [deps:frontend, build:visualiser-wasm]
cmds:
- "{{.NPM}} run build-frontend"
build:visualiser-wasm:
aliases: [build:viz-wasm]
desc: Build Go network visualiser WASM into frontend public vendor/
cmds:
- node scripts/build-visualiser-wasm.mjs
test:visualiser-wasm:
aliases: [test:viz-wasm]
desc: Run Go unit tests for visualiser-wasm
dir: visualiser-wasm
env:
GOCACHE: ../.gocache
GOTMPDIR: ../.gotmp
cmds:
- mkdir -p ../.gocache ../.gotmp
- go test ./internal/...
test:visualiser-wasm:race:
aliases: [test:viz-wasm:race]
desc: Run visualiser-wasm tests with the race detector
dir: visualiser-wasm
env:
GOCACHE: ../.gocache
GOTMPDIR: ../.gotmp
CGO_ENABLED: "1"
cmds:
- mkdir -p ../.gocache ../.gotmp
- go test -race -count=1 ./internal/...
test:visualiser-wasm:fuzz:
aliases: [test:viz-wasm:fuzz]
desc: Run short visualiser-wasm fuzz targets
dir: visualiser-wasm
env:
GOCACHE: ../.gocache
GOTMPDIR: ../.gotmp
cmds:
- mkdir -p ../.gocache ../.gotmp
- go test ./internal/hashpos -fuzz=FuzzAngle01 -fuzztime=5s
- go test ./internal/filter -fuzz=FuzzPathHashesWithinHopFilter -fuzztime=5s
- go test ./internal/filter -fuzz=FuzzMatchesSearch -fuzztime=3s
- go test ./internal/icon -fuzz=FuzzDedupeQueueEntries -fuzztime=5s
- go test ./internal/lod -fuzz=FuzzLevelFromScale -fuzztime=3s
- go test ./internal/lod -fuzz=FuzzComputeUpdates -fuzztime=5s
- go test ./internal/graph -fuzz=FuzzBuildPathGraph -fuzztime=8s
bench:visualiser-wasm:
aliases: [bench:viz-wasm]
desc: Run visualiser-wasm benchmarks with allocation reporting
dir: visualiser-wasm
env:
GOCACHE: ../.gocache
GOTMPDIR: ../.gotmp
cmds:
- mkdir -p ../.gocache ../.gotmp
- go test ./internal/... -run='^$' -bench=. -benchmem -count=1
build:wheel:
desc: Build Python wheel package
deps: [install]

View file

@ -48,6 +48,8 @@ export default [
__APP_BUILD_TIME__: "readonly",
__MICRON_WASM_SRI_WASM__: "readonly",
__MICRON_WASM_SRI_EXEC__: "readonly",
__VISUALISER_WASM_SRI_WASM__: "readonly",
__VISUALISER_WASM_SRI_EXEC__: "readonly",
axios: "readonly",
Codec2Lib: "readonly",
Codec2MicrophoneRecorder: "readonly",

Binary file not shown.

View file

@ -5943,6 +5943,91 @@ class ReticulumMeshChat:
},
)
@routes.post("/api/v1/reticulum/interfaces/bitrates")
async def reticulum_interfaces_bitrates(request):
"""Set forced bitrate (bps) on named interfaces and optionally reload RNS."""
try:
data = await request.json()
except Exception:
return web.json_response({"message": "Invalid JSON"}, status=400)
if not isinstance(data, dict):
return web.json_response({"message": "Invalid JSON object"}, status=400)
bitrates = data.get("bitrates")
if not isinstance(bitrates, dict) or not bitrates:
return web.json_response(
{"message": "bitrates object is required"},
status=422,
)
reload_stack = bool(data.get("reload", False))
interfaces = self._get_interfaces_section()
interfaces_before_write = self._get_interfaces_snapshot()
updated = []
missing = []
for raw_name, raw_bps in bitrates.items():
name = InterfaceEditor.sanitize_interface_section_name(str(raw_name))
if not name or name not in interfaces:
missing.append(str(raw_name))
continue
details = interfaces[name]
if raw_bps is None or raw_bps == "":
details.pop("bitrate", None)
else:
try:
bps = int(raw_bps)
except (TypeError, ValueError):
return web.json_response(
{"message": f"Invalid bitrate for {name}"},
status=422,
)
if bps < 0:
return web.json_response(
{"message": f"Bitrate must be >= 0 for {name}"},
status=422,
)
details["bitrate"] = str(bps)
updated.append(name)
if not updated and missing:
return web.json_response(
{"message": "No matching interfaces", "missing": missing},
status=404,
)
if updated and not self._write_reticulum_config(
rollback_interfaces=interfaces_before_write
):
return web.json_response(
{"message": "Failed to write Reticulum config"},
status=500,
)
reloaded = False
if reload_stack and updated:
try:
await self.reload_reticulum()
reloaded = True
except Exception as e:
return web.json_response(
{
"message": f"Bitrates saved but RNS reload failed: {e}",
"updated": updated,
"missing": missing,
"reloaded": False,
},
status=500,
)
return web.json_response(
{
"message": "Interface bitrates updated",
"updated": updated,
"missing": missing,
"reloaded": reloaded,
},
)
# fetch community interfaces
@routes.get("/api/v1/community-interfaces")
async def community_interfaces(request):
@ -7437,6 +7522,16 @@ class ReticulumMeshChat:
except Exception:
return None
def _safe_resource_breakdown():
try:
from meshchatx.src.backend.process_resource_breakdown import (
build_resource_breakdown,
)
return build_resource_breakdown(process)
except Exception:
return []
def _safe_net_io():
try:
return psutil.net_io_counters()
@ -7454,6 +7549,7 @@ class ReticulumMeshChat:
memory_info = _safe_memory_info()
process_usage = _safe_process_usage()
battery_usage = _safe_battery_usage()
resource_breakdown = _safe_resource_breakdown()
net_io = _safe_net_io()
def _safe_database_path():
@ -7684,6 +7780,7 @@ class ReticulumMeshChat:
"create_time": process_usage.get("create_time"),
"cpu_time_seconds": process_usage.get("cpu_time_seconds"),
},
"resource_breakdown": resource_breakdown,
"battery_usage": battery_usage,
"network_stats": {
"bytes_sent": net_io.bytes_sent,

View file

@ -0,0 +1,94 @@
# SPDX-License-Identifier: 0BSD
"""Process RSS/CPU breakdown helpers for About usage insights."""
from __future__ import annotations
def _safe_child_name(proc) -> str:
try:
name = proc.name()
if name:
return str(name)[:64]
except Exception:
pass
try:
return f"pid:{proc.pid}"
except Exception:
return "child"
def build_resource_breakdown(process, *, max_children: int = 8) -> list[dict]:
"""Return process and child RSS/CPU rows sorted by RSS descending.
Values are best-effort. Restricted hosts (Android Landlock) may return
only the parent row or an empty list.
"""
if process is None:
return []
rows: list[dict] = []
def add_row(label: str, proc) -> None:
rss = None
cpu = None
try:
rss = int(proc.memory_info().rss)
except Exception:
rss = None
try:
# Non-blocking sample. First call after create may be 0.0.
cpu = float(proc.cpu_percent(interval=None))
except Exception:
cpu = None
if rss is None and cpu is None:
return
rows.append(
{
"name": label,
"rss": rss,
"cpu_percent": cpu,
}
)
add_row("backend", process)
try:
children = list(process.children(recursive=True))
except Exception:
children = []
# Prefer largest children so About can show a useful top consumer.
scored = []
for child in children:
try:
scored.append((int(child.memory_info().rss), child))
except Exception:
try:
scored.append((0, child))
except Exception:
continue
scored.sort(key=lambda item: item[0], reverse=True)
for _, child in scored[: max(0, int(max_children))]:
add_row(f"child:{_safe_child_name(child)}", child)
rows.sort(key=lambda row: int(row.get("rss") or 0), reverse=True)
return rows
def top_by_rss(rows: list[dict] | None) -> dict | None:
if not rows:
return None
best = max(rows, key=lambda row: int(row.get("rss") or 0))
if best.get("rss") is None:
return None
return best
def top_by_cpu(rows: list[dict] | None) -> dict | None:
if not rows:
return None
scored = [row for row in rows if row.get("cpu_percent") is not None]
if not scored:
return None
return max(scored, key=lambda row: float(row.get("cpu_percent") or 0.0))

View file

@ -611,6 +611,11 @@ import { handleLxmIngestUriResult } from "../js/ingestUriResultNavigation.js";
import { applyRelayShareLink, parseMeshchatRelayUri } from "../js/relayLinkUtils.js";
import logoUrl from "../assets/images/logo.png";
import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../js/browserLayoutStore";
import {
applyBackgroundPollInterval,
BATTERY_SAVER_CHANGED_EVENT,
loadBatterySaverPrefs,
} from "../js/settings/batterySaverPrefs.js";
export default {
name: "App",
@ -961,20 +966,48 @@ export default {
this.updateTelephoneStatus();
this.updatePropagationNodeStatus();
this.reloadInterval = setInterval(() => {
this.updateTelephoneStatus();
this.updatePropagationNodeStatus();
}, 1000);
this.appInfoInterval = setInterval(() => {
this.getAppInfo();
}, 15000);
this.unreadCountInterval = setInterval(() => {
this.updateUnreadConversationsCount();
this.updateRelayChatUnreadCount();
}, 5000);
GlobalEmitter.on(BATTERY_SAVER_CHANGED_EVENT, this.onBatterySaverPrefsChangedShell);
this.startShellPollIntervals();
this.updateUnreadConversationsCount();
this.updateRelayChatUnreadCount();
},
startShellPollIntervals() {
clearInterval(this.reloadInterval);
clearInterval(this.appInfoInterval);
clearInterval(this.unreadCountInterval);
this.reloadInterval = null;
this.appInfoInterval = null;
this.unreadCountInterval = null;
if (!this.shellRunning) {
return;
}
const prefs = loadBatterySaverPrefs();
this.reloadInterval = setInterval(
() => {
this.updateTelephoneStatus();
this.updatePropagationNodeStatus();
},
applyBackgroundPollInterval(1000, prefs)
);
this.appInfoInterval = setInterval(
() => {
this.getAppInfo();
},
applyBackgroundPollInterval(15000, prefs)
);
this.unreadCountInterval = setInterval(
() => {
this.updateUnreadConversationsCount();
this.updateRelayChatUnreadCount();
},
applyBackgroundPollInterval(5000, prefs)
);
},
onBatterySaverPrefsChangedShell() {
if (this.shellRunning) {
this.startShellPollIntervals();
}
},
stopShell() {
if (!this.shellRunning) {
return;
@ -986,6 +1019,7 @@ export default {
this.appInfoInterval = null;
clearInterval(this.unreadCountInterval);
this.unreadCountInterval = null;
GlobalEmitter.off(BATTERY_SAVER_CHANGED_EVENT, this.onBatterySaverPrefsChangedShell);
WebSocketConnection.off("disconnected", this.onWsShellDisconnected);
WebSocketConnection.off("connected", this.onWsShellConnected);
this.unregisterShellWsHandlers();

View file

@ -459,104 +459,6 @@
}}</span>
<span class="font-mono text-xs font-bold">{{ environmentInfo.platform }}</span>
</div>
<div
v-if="appInfo.memory_usage || appInfo.battery_usage"
class="flex flex-col gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800"
>
<span class="text-[10px] font-black text-cyan-600 uppercase tracking-wider">{{
$t("about.usage_insights")
}}</span>
<div v-if="batteryUsageLabel" class="flex items-center justify-between gap-3">
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.app_battery_use")
}}</span>
<span
class="font-mono text-xs font-bold tabular-nums shrink-0"
:class="batteryUsageToneClass"
:title="$t('about.app_battery_use_hint')"
>
{{ batteryUsageLabel }}
</span>
</div>
<div v-if="batteryUsageShareLabel" class="flex items-center justify-between gap-3">
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.app_battery_share")
}}</span>
<span class="font-mono text-xs font-bold tabular-nums">{{
batteryUsageShareLabel
}}</span>
</div>
<div v-if="appInfo.memory_usage" class="flex items-center justify-between gap-3">
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.memory_rss")
}}</span>
<span class="font-mono text-xs font-bold tabular-nums">{{
formatBytes(appInfo.memory_usage.rss || 0)
}}</span>
</div>
<div v-if="appInfo.memory_usage" class="flex items-center justify-between gap-3">
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.virtual_memory")
}}</span>
<span class="font-mono text-xs font-bold tabular-nums">{{
formatBytes(appInfo.memory_usage.vms || 0)
}}</span>
</div>
<div
v-if="appInfo.memory_usage && appInfo.memory_usage.cpu_percent != null"
class="flex items-center justify-between gap-3"
>
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.process_cpu")
}}</span>
<span class="font-mono text-xs font-bold tabular-nums">{{
formatCpuPercent(appInfo.memory_usage.cpu_percent)
}}</span>
</div>
<div
v-if="appInfo.memory_usage && appInfo.memory_usage.num_threads != null"
class="flex items-center justify-between gap-3"
>
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.process_threads")
}}</span>
<span class="font-mono text-xs font-bold tabular-nums">{{
appInfo.memory_usage.num_threads
}}</span>
</div>
<div v-if="processUptimeLabel" class="flex items-center justify-between gap-3">
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.process_uptime")
}}</span>
<span class="font-mono text-xs font-bold tabular-nums">{{
processUptimeLabel
}}</span>
</div>
<div v-if="memoryPressureLabel" class="flex items-center justify-between gap-3">
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.memory_pressure")
}}</span>
<span class="font-mono text-xs font-bold tabular-nums">{{
memoryPressureLabel
}}</span>
</div>
<div v-if="showHostBattery" class="flex items-center justify-between gap-3">
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.env_host_battery")
}}</span>
<span
class="font-mono text-xs font-bold shrink-0 inline-flex items-center gap-1"
:class="batteryStatusToneClass"
>
<v-icon
v-if="batteryStatus"
:icon="'mdi-' + batteryStatusIcon"
size="14"
></v-icon>
{{ batteryStatusLabel }}
</span>
</div>
</div>
<div
v-if="isLinuxHost && appInfo.landlock_requested !== undefined"
class="flex flex-col gap-1"
@ -611,6 +513,174 @@
</div>
</div>
<!-- Usage and battery -->
<div
v-if="
appInfo &&
(appInfo.memory_usage || appInfo.battery_usage || showHostBattery || batterySaverPrefs)
"
class="about-section"
>
<div
class="text-xs font-black text-cyan-600 uppercase tracking-[0.2em] mb-6 flex items-center gap-2"
>
<v-icon icon="mdi-gauge" size="14"></v-icon>
{{ $t("about.usage_insights") }}
</div>
<div
class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 text-sm min-w-0 rounded-xl border border-gray-200/60 dark:border-zinc-800/80 p-4 sm:bg-black/2 dark:sm:bg-white/2"
>
<div class="flex items-center justify-between gap-3 sm:col-span-2 lg:col-span-3">
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.battery_saver")
}}</span>
<span
class="font-mono text-xs font-bold tabular-nums shrink-0"
:class="
batterySaverPrefs.enabled
? 'text-emerald-600 dark:text-emerald-400'
: 'opacity-70'
"
>
{{
batterySaverPrefs.enabled
? $t("about.battery_saver_on")
: $t("about.battery_saver_off")
}}
</span>
</div>
<div
v-if="batterySaverActiveMeasures.length"
class="sm:col-span-2 lg:col-span-3 space-y-1.5"
>
<div class="text-[10px] font-semibold uppercase tracking-wider opacity-70">
{{ $t("about.battery_saver_measures") }}
</div>
<ul
class="text-xs grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1 list-disc list-inside opacity-90"
>
<li v-for="measure in batterySaverActiveMeasures" :key="measure">
{{ $t(`about.battery_saver_measure.${measure}`) }}
</li>
</ul>
</div>
<div v-if="topMemoryConsumerLabel" class="flex items-center justify-between gap-3">
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.top_memory_consumer")
}}</span>
<span
class="font-mono text-xs font-bold tabular-nums text-right shrink-0 max-w-[70%]"
:title="topMemoryConsumerLabel"
>
{{ topMemoryConsumerLabel }}
</span>
</div>
<div v-if="topCpuConsumerLabel" class="flex items-center justify-between gap-3">
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.top_cpu_consumer")
}}</span>
<span
class="font-mono text-xs font-bold tabular-nums text-right shrink-0 max-w-[70%]"
:title="topCpuConsumerLabel"
>
{{ topCpuConsumerLabel }}
</span>
</div>
<div v-if="batteryUsageLabel" class="flex items-center justify-between gap-3">
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.app_battery_use")
}}</span>
<span
class="font-mono text-xs font-bold tabular-nums shrink-0"
:class="batteryUsageToneClass"
:title="$t('about.app_battery_use_hint')"
>
{{ batteryUsageLabel }}
</span>
</div>
<div v-if="batteryUsageShareLabel" class="flex items-center justify-between gap-3">
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.app_battery_share")
}}</span>
<span class="font-mono text-xs font-bold tabular-nums">{{
batteryUsageShareLabel
}}</span>
</div>
<div v-if="appInfo.memory_usage" class="flex items-center justify-between gap-3">
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.memory_rss")
}}</span>
<span class="font-mono text-xs font-bold tabular-nums">{{
formatBytes(appInfo.memory_usage.rss || 0)
}}</span>
</div>
<div v-if="appInfo.memory_usage" class="flex items-center justify-between gap-3">
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.virtual_memory")
}}</span>
<span class="font-mono text-xs font-bold tabular-nums">{{
formatBytes(appInfo.memory_usage.vms || 0)
}}</span>
</div>
<div
v-if="appInfo.memory_usage && appInfo.memory_usage.cpu_percent != null"
class="flex items-center justify-between gap-3"
>
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.process_cpu")
}}</span>
<span class="font-mono text-xs font-bold tabular-nums">{{
formatCpuPercent(appInfo.memory_usage.cpu_percent)
}}</span>
</div>
<div
v-if="appInfo.memory_usage && appInfo.memory_usage.num_threads != null"
class="flex items-center justify-between gap-3"
>
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.process_threads")
}}</span>
<span class="font-mono text-xs font-bold tabular-nums">{{
appInfo.memory_usage.num_threads
}}</span>
</div>
<div v-if="processUptimeLabel" class="flex items-center justify-between gap-3">
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.process_uptime")
}}</span>
<span class="font-mono text-xs font-bold tabular-nums">{{ processUptimeLabel }}</span>
</div>
<div v-if="pathTableSizeLabel" class="flex items-center justify-between gap-3">
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.path_table")
}}</span>
<span class="font-mono text-xs font-bold tabular-nums">{{ pathTableSizeLabel }}</span>
</div>
<div v-if="memoryPressureLabel" class="flex items-center justify-between gap-3">
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.memory_pressure")
}}</span>
<span
class="font-mono text-xs font-bold tabular-nums"
:class="memoryPressureToneClass"
>{{ memoryPressureLabel }}</span
>
</div>
<div v-if="showHostBattery" class="flex items-center justify-between gap-3">
<span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
$t("about.env_host_battery")
}}</span>
<span
class="font-mono text-xs font-bold shrink-0 inline-flex items-center gap-1"
:class="batteryStatusToneClass"
>
<v-icon v-if="batteryStatus" :icon="'mdi-' + batteryStatusIcon" size="14"></v-icon>
{{ batteryStatusLabel }}
</span>
</div>
</div>
</div>
<!-- Dependency Chain -->
<div v-if="appInfo" class="about-section">
<div
@ -1180,6 +1250,13 @@ import {
getDeviceBatteryStatus,
isNativeBatteryStatus,
} from "../../js/deviceBattery.js";
import {
activeBatterySaverMeasures,
applyBackgroundPollInterval,
BATTERY_SAVER_CHANGED_EVENT,
loadBatterySaverPrefs,
} from "../../js/settings/batterySaverPrefs.js";
import { mergeResourceBreakdown, topResourceByCpu, topResourceByRss } from "../../js/resourceBreakdown.js";
export default {
name: "AboutPage",
components: {},
@ -1200,6 +1277,7 @@ export default {
healthLoading: false,
electronMemoryUsage: null,
batteryStatus: null,
batterySaverPrefs: loadBatterySaverPrefs(),
backupInProgress: false,
backupMessage: "",
backupError: "",
@ -1284,22 +1362,53 @@ export default {
batteryUsageToneClass() {
return appBatteryUsageToneClass(this.appInfo?.battery_usage);
},
batterySaverActiveMeasures() {
return activeBatterySaverMeasures(this.batterySaverPrefs);
},
resourceBreakdownRows() {
return mergeResourceBreakdown(this.appInfo?.resource_breakdown, this.electronMemoryUsage);
},
topMemoryConsumerLabel() {
const top = topResourceByRss(this.resourceBreakdownRows);
if (!top) return "";
return `${top.name} (${this.formatBytes(top.rss || 0)})`;
},
topCpuConsumerLabel() {
const top = topResourceByCpu(this.resourceBreakdownRows);
if (!top) return "";
return `${top.name} (${this.formatCpuPercent(top.cpu_percent)})`;
},
processUptimeLabel() {
return formatProcessUptime(this.appInfo?.memory_usage?.create_time);
},
pathTableSizeLabel() {
const cleanup = this.appInfo?.reticulum_stats?.memory_cleanup;
const total = this.appInfo?.reticulum_stats?.total_paths;
const pathSize =
cleanup && typeof cleanup === "object" && cleanup.path_table_size != null
? cleanup.path_table_size
: total;
if (pathSize == null) {
return null;
}
return this.$t("about.path_table_count", { count: pathSize });
},
memoryPressureLabel() {
const cleanup = this.appInfo?.reticulum_stats?.memory_cleanup;
if (!cleanup || typeof cleanup !== "object") {
return null;
return this.$t("about.memory_pressure_normal");
}
if (cleanup.sqlite_relaxed) {
return this.$t("about.memory_pressure_relaxed");
}
const pathSize = cleanup.path_table_size;
if (pathSize != null) {
return this.$t("about.memory_pressure_paths", { count: pathSize });
return this.$t("about.memory_pressure_normal");
},
memoryPressureToneClass() {
const cleanup = this.appInfo?.reticulum_stats?.memory_cleanup;
if (cleanup && typeof cleanup === "object" && cleanup.sqlite_relaxed) {
return "text-amber-600 dark:text-amber-400";
}
return null;
return "opacity-70";
},
batteryStatusLabel() {
if (!this.batteryStatus || !this.batteryStatus.supported) {
@ -1371,13 +1480,12 @@ export default {
this.getDatabaseHealth();
this.listSnapshots();
this.listAutoBackups();
// Update stats every 5 seconds
this.updateInterval = setInterval(() => {
this.getAppInfo();
}, 5000);
this.healthInterval = setInterval(() => {
this.getDatabaseHealth();
}, 30000);
this._batterySaverPrefsHandler = (prefs) => {
this.batterySaverPrefs = prefs || loadBatterySaverPrefs();
this.restartAboutPollIntervals();
};
GlobalEmitter.on(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
this.restartAboutPollIntervals();
},
beforeUnmount() {
if (this.updateInterval) {
@ -1386,8 +1494,32 @@ export default {
if (this.healthInterval) {
clearInterval(this.healthInterval);
}
if (this._batterySaverPrefsHandler) {
GlobalEmitter.off(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
}
},
methods: {
restartAboutPollIntervals() {
if (this.updateInterval) {
clearInterval(this.updateInterval);
}
if (this.healthInterval) {
clearInterval(this.healthInterval);
}
const prefs = this.batterySaverPrefs || loadBatterySaverPrefs();
this.updateInterval = setInterval(
() => {
this.getAppInfo();
},
applyBackgroundPollInterval(5000, prefs)
);
this.healthInterval = setInterval(
() => {
this.getDatabaseHealth();
},
applyBackgroundPollInterval(30000, prefs)
);
},
async listSnapshots() {
try {
const response = await window.api.get("/api/v1/database/snapshots", {

View file

@ -730,6 +730,8 @@ import ToastUtils from "../../js/ToastUtils";
import GlobalState from "../../js/GlobalState";
import Toggle from "../forms/Toggle.vue";
import BundledDocsHint from "./BundledDocsHint.vue";
import GlobalEmitter from "../../js/GlobalEmitter";
import { BATTERY_SAVER_CHANGED_EVENT, loadBatterySaverPrefs } from "../../js/settings/batterySaverPrefs.js";
export default {
name: "InterfacesPage",
@ -934,6 +936,9 @@ export default {
beforeUnmount() {
clearInterval(this.reloadInterval);
clearInterval(this.discoveryInterval);
if (this._batterySaverPrefsHandler) {
GlobalEmitter.off(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
}
},
mounted() {
try {
@ -953,16 +958,30 @@ export default {
this.loadDiscoveryConfig();
this.loadDiscoveredInterfaces();
// update info every few seconds
this.reloadInterval = setInterval(() => {
this.updateInterfaceStats();
}, 1000);
this.discoveryInterval = setInterval(() => {
this.loadDiscoveredInterfaces();
}, 5000);
this._batterySaverPrefsHandler = () => {
this.startInterfacePollIntervals();
};
GlobalEmitter.on(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
this.startInterfacePollIntervals();
},
methods: {
startInterfacePollIntervals() {
clearInterval(this.reloadInterval);
clearInterval(this.discoveryInterval);
this.reloadInterval = null;
this.discoveryInterval = null;
const prefs = loadBatterySaverPrefs();
const statsMs =
prefs.enabled && prefs.reduceInterfacesDiscovery ? prefs.interfacesStatsPollSeconds * 1000 : 1000;
const discoveryMs =
prefs.enabled && prefs.reduceInterfacesDiscovery ? prefs.interfacesDiscoveryPollSeconds * 1000 : 5000;
this.reloadInterval = setInterval(() => {
this.updateInterfaceStats();
}, statsMs);
this.discoveryInterval = setInterval(() => {
this.loadDiscoveredInterfaces();
}, discoveryMs);
},
relaunch() {
ElectronUtils.relaunch();
},

View file

@ -23,61 +23,23 @@
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center justify-center w-8 h-8 sm:w-9 sm:h-9 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white transition-all active:scale-95"
class="inline-flex items-center justify-center w-8 h-8 sm:w-9 sm:h-9 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white transition-all active:scale-95 disabled:opacity-60"
:disabled="isUpdating || isLoading"
:aria-label="$t('visualiser.refresh')"
@click.stop="$emit('manual-update')"
>
<svg
v-if="!isUpdating && !isLoading"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
<MaterialDesignIcon
:icon-name="isUpdating || isLoading ? 'loading' : 'refresh'"
class="w-4 h-4 sm:w-5 sm:h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
<svg
v-else
class="animate-spin h-4 w-4 sm:w-5 sm:h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
:class="{ 'animate-spin': isUpdating || isLoading }"
/>
</button>
<div class="w-5 sm:w-6 flex justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
<MaterialDesignIcon
icon-name="chevron-down"
class="w-4 h-4 sm:w-5 sm:h-5 text-gray-400 transition-transform duration-300"
:class="{ 'rotate-180': isShowingControls }"
>
<path
fill-rule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
/>
</div>
</div>
</div>
@ -88,11 +50,39 @@
>
<div class="h-px bg-linear-to-r from-transparent via-gray-200 dark:via-zinc-800 to-transparent"></div>
<div class="grid grid-cols-2 gap-2">
<div
class="rounded-xl px-3 py-2 border border-gray-100 dark:border-zinc-700/50 bg-gray-50/60 dark:bg-zinc-800/40"
:title="engineModeTitle"
>
<div
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-0.5"
>
{{ $t("visualiser.engine") }}
</div>
<div class="text-xs font-bold truncate" :class="engineModeClass">
{{ engineModeLabel }}
</div>
</div>
<div
class="rounded-xl px-3 py-2 border border-gray-100 dark:border-zinc-700/50 bg-gray-50/60 dark:bg-zinc-800/40"
>
<div
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-0.5"
>
{{ $t("visualiser.fps") }}
</div>
<div class="text-xs font-bold text-gray-800 dark:text-zinc-100 tabular-nums">
{{ fpsDisplay }}
</div>
</div>
</div>
<div class="flex items-center justify-between">
<label
for="auto-reload"
class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
>Auto Update</label
>{{ $t("visualiser.auto_update") }}</label
>
<Toggle
id="auto-reload"
@ -105,7 +95,7 @@
<label
for="enable-physics"
class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
>Live Layout</label
>{{ $t("visualiser.live_layout") }}</label
>
<Toggle
id="enable-physics"
@ -156,7 +146,7 @@
<div
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-1"
>
Nodes
{{ $t("visualiser.nodes") }}
</div>
<div class="text-lg font-bold text-blue-600 dark:text-blue-400">{{ nodeCount }}</div>
</div>
@ -166,7 +156,7 @@
<div
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-1"
>
Links
{{ $t("visualiser.links") }}
</div>
<div class="text-lg font-bold text-emerald-600 dark:text-emerald-400">{{ edgeCount }}</div>
</div>
@ -176,19 +166,19 @@
class="bg-zinc-950/5 dark:bg-white/5 rounded-xl p-3 border border-gray-100 dark:border-zinc-700/50"
>
<div class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-2">
Interfaces
{{ $t("visualiser.interfaces") }}
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-emerald-500"></div>
<span class="text-xs font-bold text-gray-700 dark:text-zinc-300"
>{{ onlineInterfaceCount }} Online</span
>{{ onlineInterfaceCount }} {{ $t("visualiser.online") }}</span
>
</div>
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-red-500"></div>
<span class="text-xs font-bold text-gray-700 dark:text-zinc-300"
>{{ offlineInterfaceCount }} Offline</span
>{{ offlineInterfaceCount }} {{ $t("visualiser.offline") }}</span
>
</div>
</div>
@ -201,18 +191,12 @@
<div
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path
fill-rule="evenodd"
d="M9 3.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM2.25 10a7.75 7.75 0 1 1 14.03 4.5l3.47 3.47a.75.75 0 0 1-1.06 1.06l-3.47-3.47A7.75 7.75 0 0 1 2.25 10Z"
clip-rule="evenodd"
/>
</svg>
<MaterialDesignIcon icon-name="magnify" class="w-4 h-4" />
</div>
<input
:value="searchQuery"
type="text"
:placeholder="`Search nodes (${nodeCount})...`"
:placeholder="$t('visualiser.search_nodes_placeholder', { count: nodeCount })"
class="block w-full sm:w-64 pl-9 pr-10 py-2.5 sm:py-3 bg-white/90 dark:bg-zinc-900/90 border border-gray-200/50 dark:border-zinc-800/50 rounded-2xl text-xs font-semibold focus:outline-hidden focus:ring-2 focus:ring-blue-500/50 sm:focus:w-80 md:max-lg:focus:w-72 lg:focus:w-80 transition-all dark:text-zinc-100 shadow-xs"
@input="$emit('update:searchQuery', $event.target.value)"
/>
@ -220,13 +204,10 @@
v-if="searchQuery"
type="button"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-zinc-200 transition-colors"
:aria-label="$t('visualiser.clear_search')"
@click="$emit('update:searchQuery', '')"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
/>
</svg>
<MaterialDesignIcon icon-name="close" class="w-4 h-4" />
</button>
</div>
</div>
@ -235,11 +216,12 @@
<script>
import Toggle from "../../forms/Toggle.vue";
import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
import { HOP_SLIDER_POS_ALL, hopSliderPosToMaxHops, hopMaxHopsToSliderPos } from "./hopMaxFilterSliderMap.js";
export default {
name: "NetworkVisualiserToolbar",
components: { Toggle },
components: { Toggle, MaterialDesignIcon },
props: {
isShowingControls: { type: Boolean, default: true },
isUpdating: { type: Boolean, default: false },
@ -257,6 +239,14 @@ export default {
onlineInterfaceCount: { type: Number, default: 0 },
offlineInterfaceCount: { type: Number, default: 0 },
searchQuery: { type: String, default: "" },
engineMode: {
type: String,
default: "checking",
validator(v) {
return ["checking", "wasm", "fallback"].includes(v);
},
},
fps: { type: Number, default: 0 },
},
emits: [
"update:isShowingControls",
@ -287,6 +277,26 @@ export default {
if (this.hopMaxFilter === null) return this.$t("visualiser.all");
return String(this.hopMaxFilter);
},
engineModeLabel() {
if (this.engineMode === "wasm") return this.$t("visualiser.engine_wasm");
if (this.engineMode === "fallback") return this.$t("visualiser.engine_fallback");
return this.$t("visualiser.engine_checking");
},
engineModeTitle() {
if (this.engineMode === "wasm") return this.$t("visualiser.engine_wasm_hint");
if (this.engineMode === "fallback") return this.$t("visualiser.engine_fallback_hint");
return this.$t("visualiser.engine_checking_hint");
},
engineModeClass() {
if (this.engineMode === "wasm") return "text-emerald-600 dark:text-emerald-400";
if (this.engineMode === "fallback") return "text-amber-600 dark:text-amber-400";
return "text-gray-500 dark:text-zinc-400";
},
fpsDisplay() {
const n = Number(this.fps);
if (!Number.isFinite(n) || n <= 0) return "--";
return String(Math.round(n));
},
},
methods: {
onHopSliderInput(e) {

View file

@ -1206,6 +1206,9 @@ export default {
};
const flush = () => {
this._layoutPersistTimer = null;
if (typeof window === "undefined" || !window.api) {
return undefined;
}
return saveNomadFavouritesLayout(window.api, layout);
};
if (options.immediate) {

View file

@ -1600,6 +1600,292 @@
</div>
</section>
<!-- Battery saver -->
<section v-show="showSection('battery')" class="settings-section break-inside-avoid">
<header class="settings-section__header">
<div>
<div class="settings-section__eyebrow">{{ $t("settings.battery.eyebrow") }}</div>
<h2>{{ $t("settings.battery.title") }}</h2>
<p>{{ $t("settings.battery.description") }}</p>
</div>
</header>
<div class="settings-section__body space-y-4">
<label class="setting-toggle">
<Toggle
id="settings-battery-saver-enabled"
v-model="batterySaver.enabled"
@update:model-value="onBatterySaverEnabledChange"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{ $t("settings.battery.enabled") }}</span>
<span class="setting-toggle__description">{{
$t("settings.battery.enabled_desc")
}}</span>
</span>
</label>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100 pt-2">
{{ $t("settings.battery.options_heading") }}
</div>
<label class="setting-toggle">
<Toggle
id="settings-battery-viz-discovery"
v-model="batterySaver.disableVisualiserDiscovery"
@update:model-value="
(v) => patchBatterySaver({ disableVisualiserDiscovery: v })
"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{
$t("settings.battery.disable_visualiser_discovery")
}}</span>
<span class="setting-toggle__description">{{
$t("settings.battery.disable_visualiser_discovery_desc")
}}</span>
</span>
</label>
<label class="setting-toggle">
<Toggle
id="settings-battery-hide-offline"
v-model="batterySaver.hideOfflineInterfaces"
@update:model-value="(v) => patchBatterySaver({ hideOfflineInterfaces: v })"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{
$t("settings.battery.hide_offline_interfaces")
}}</span>
<span class="setting-toggle__description">{{
$t("settings.battery.hide_offline_interfaces_desc")
}}</span>
</span>
</label>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("settings.battery.max_visualiser_interfaces") }}
</div>
<p class="text-xs text-gray-500 dark:text-zinc-400">
{{ $t("settings.battery.max_visualiser_interfaces_desc") }}
</p>
<input
v-model.number="batterySaver.maxVisualiserInterfaces"
type="number"
min="0"
max="128"
class="input-field"
@change="
patchBatterySaver({
maxVisualiserInterfaces: batterySaver.maxVisualiserInterfaces,
})
"
/>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("settings.battery.visualiser_reload_seconds") }}
</div>
<p class="text-xs text-gray-500 dark:text-zinc-400">
{{ $t("settings.battery.visualiser_reload_seconds_desc") }}
</p>
<input
v-model.number="batterySaver.visualiserReloadSeconds"
type="number"
min="0"
max="600"
class="input-field"
@change="
patchBatterySaver({
visualiserReloadSeconds: batterySaver.visualiserReloadSeconds,
})
"
/>
</div>
<label class="setting-toggle">
<Toggle
id="settings-battery-live-layout"
v-model="batterySaver.disableVisualiserLiveLayout"
@update:model-value="
(v) => patchBatterySaver({ disableVisualiserLiveLayout: v })
"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{
$t("settings.battery.disable_visualiser_live_layout")
}}</span>
<span class="setting-toggle__description">{{
$t("settings.battery.disable_visualiser_live_layout_desc")
}}</span>
</span>
</label>
<label class="setting-toggle">
<Toggle
id="settings-battery-bg-poll"
v-model="batterySaver.reduceBackgroundPolling"
@update:model-value="(v) => patchBatterySaver({ reduceBackgroundPolling: v })"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{
$t("settings.battery.reduce_background_polling")
}}</span>
<span class="setting-toggle__description">{{
$t("settings.battery.reduce_background_polling_desc")
}}</span>
</span>
</label>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("settings.battery.background_poll_multiplier") }}
</div>
<p class="text-xs text-gray-500 dark:text-zinc-400">
{{ $t("settings.battery.background_poll_multiplier_desc") }}
</p>
<input
v-model.number="batterySaver.backgroundPollMultiplier"
type="number"
min="2"
max="10"
class="input-field"
@change="
patchBatterySaver({
backgroundPollMultiplier: batterySaver.backgroundPollMultiplier,
})
"
/>
</div>
<label class="setting-toggle">
<Toggle
id="settings-battery-ifaces-discovery"
v-model="batterySaver.reduceInterfacesDiscovery"
@update:model-value="(v) => patchBatterySaver({ reduceInterfacesDiscovery: v })"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{
$t("settings.battery.reduce_interfaces_discovery")
}}</span>
<span class="setting-toggle__description">{{
$t("settings.battery.reduce_interfaces_discovery_desc")
}}</span>
</span>
</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("settings.battery.interfaces_stats_poll_seconds") }}
</div>
<input
v-model.number="batterySaver.interfacesStatsPollSeconds"
type="number"
min="1"
max="120"
class="input-field"
@change="
patchBatterySaver({
interfacesStatsPollSeconds: batterySaver.interfacesStatsPollSeconds,
})
"
/>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("settings.battery.interfaces_discovery_poll_seconds") }}
</div>
<input
v-model.number="batterySaver.interfacesDiscoveryPollSeconds"
type="number"
min="5"
max="300"
class="input-field"
@change="
patchBatterySaver({
interfacesDiscoveryPollSeconds:
batterySaver.interfacesDiscoveryPollSeconds,
})
"
/>
</div>
</div>
<label class="setting-toggle">
<Toggle
id="settings-battery-bitrate-limits"
v-model="batterySaver.applyInterfaceBitrateLimits"
@update:model-value="
(v) => patchBatterySaver({ applyInterfaceBitrateLimits: v === true })
"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{
$t("settings.battery.apply_interface_bitrate_limits")
}}</span>
<span class="setting-toggle__description">{{
$t("settings.battery.apply_interface_bitrate_limits_desc")
}}</span>
</span>
</label>
<div v-if="batterySaver.applyInterfaceBitrateLimits" class="space-y-3">
<p class="text-xs text-gray-500 dark:text-zinc-400">
{{ $t("settings.battery.interface_bitrate_limits_help") }}
</p>
<div
v-if="batteryInterfaceRows.length === 0"
class="text-xs text-gray-500 dark:text-zinc-400"
>
{{ $t("settings.battery.interface_bitrate_limits_empty") }}
</div>
<div
v-for="row in batteryInterfaceRows"
:key="row.name"
class="grid grid-cols-1 sm:grid-cols-[1fr_10rem] gap-2 items-center"
>
<div
class="text-sm text-gray-900 dark:text-gray-100 truncate"
:title="row.name"
>
{{ row.name }}
<span class="text-xs text-gray-500 dark:text-zinc-400">
({{ row.type || "?" }})
</span>
</div>
<input
v-model.number="batterySaver.interfaceBitrateLimits[row.name]"
type="number"
min="0"
class="input-field"
:placeholder="$t('settings.battery.interface_bitrate_placeholder')"
@change="onBatteryBitrateLimitChange(row.name)"
/>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="secondary-button text-sm"
:disabled="batteryBitrateBusy"
@click="applyBatteryBitrateLimitsNow"
>
{{ $t("settings.battery.apply_bitrates_reload") }}
</button>
<button
type="button"
class="secondary-button text-sm"
:disabled="batteryBitrateBusy"
@click="restoreBatteryBitrateLimitsNow"
>
{{ $t("settings.battery.restore_bitrates_reload") }}
</button>
</div>
</div>
</div>
</section>
<!-- Network Visualiser -->
<section v-show="showSection('visualiser')" class="settings-section break-inside-avoid">
<header class="settings-section__header">
@ -3296,6 +3582,11 @@ import {
persistVisualiserShowDisabled,
persistVisualiserShowDiscovered,
} from "../../js/settings/settingsVisualiserPrefs";
import { loadBatterySaverPrefs, saveBatterySaverPrefs } from "../../js/settings/batterySaverPrefs.js";
import {
applyBatterySaverBitrateLimits,
restoreBatterySaverBitrateLimits,
} from "../../js/settings/batterySaverBitrateApply.js";
import {
incomingDeliveryBytesFromCustom,
incomingDeliveryBytesFromPresetKey,
@ -3461,6 +3752,9 @@ export default {
gifImportReplaceDuplicates: false,
visualiserShowDisabledInterfaces: false,
visualiserShowDiscoveredInterfaces: false,
batterySaver: loadBatterySaverPrefs(),
batteryInterfaceRows: [],
batteryBitrateBusy: false,
selfTestRunning: false,
selfTestResults: null,
selfTestExpandedReasons: {},
@ -3703,10 +3997,108 @@ export default {
this.loadStickerCount();
this.loadGifCount();
this.loadVisualiserDisplayPrefsFromStorage();
this.loadBatterySaverPrefsFromStorage();
this.loadBatteryInterfaceRows();
this.loadDesktopCloseSettings();
this.loadReticulumInstanceSettings();
},
methods: {
loadBatterySaverPrefsFromStorage() {
this.batterySaver = loadBatterySaverPrefs();
if (!this.batterySaver.interfaceBitrateLimits) {
this.batterySaver.interfaceBitrateLimits = {};
}
},
async loadBatteryInterfaceRows() {
try {
const response = await window.api.get("/api/v1/reticulum/interfaces");
const interfaces = response?.data?.interfaces || {};
this.batteryInterfaceRows = Object.entries(interfaces)
.map(([name, iface]) => ({
name,
type: iface?.type || "",
bitrate: iface?.bitrate ?? null,
}))
.sort((a, b) => a.name.localeCompare(b.name));
for (const row of this.batteryInterfaceRows) {
if (this.batterySaver.interfaceBitrateLimits[row.name] == null && row.bitrate != null) {
const n = Number(row.bitrate);
if (Number.isFinite(n) && n >= 0) {
// leave unset so empty means "no forced limit"
}
}
}
} catch {
this.batteryInterfaceRows = [];
}
},
patchBatterySaver(patch) {
this.batterySaver = saveBatterySaverPrefs(patch);
if (!this.batterySaver.interfaceBitrateLimits) {
this.batterySaver.interfaceBitrateLimits = {};
}
},
onBatterySaverEnabledChange(val) {
const enabled = val === true;
this.patchBatterySaver({ enabled });
if (enabled && this.batterySaver.applyInterfaceBitrateLimits) {
this.applyBatteryBitrateLimitsNow();
} else if (!enabled && Object.keys(this.batterySaver.interfaceBitratePrevious || {}).length > 0) {
this.restoreBatteryBitrateLimitsNow();
}
},
onBatteryBitrateLimitChange(name) {
const limits = { ...(this.batterySaver.interfaceBitrateLimits || {}) };
const raw = limits[name];
if (raw === "" || raw == null || Number.isNaN(Number(raw))) {
delete limits[name];
} else {
limits[name] = Math.max(0, Math.round(Number(raw)));
}
this.patchBatterySaver({ interfaceBitrateLimits: limits });
},
async applyBatteryBitrateLimitsNow() {
if (this.batteryBitrateBusy) return;
this.batteryBitrateBusy = true;
try {
this.patchBatterySaver({
applyInterfaceBitrateLimits: true,
interfaceBitrateLimits: { ...(this.batterySaver.interfaceBitrateLimits || {}) },
});
const result = await applyBatterySaverBitrateLimits({ reload: true });
this.loadBatterySaverPrefsFromStorage();
if (result.updated.length === 0) {
ToastUtils.error(this.$t("settings.battery.bitrates_none_applied"));
} else {
ToastUtils.success(this.$t("settings.battery.bitrates_applied", { count: result.updated.length }));
}
await this.loadBatteryInterfaceRows();
} catch (e) {
console.error(e);
ToastUtils.error(this.$t("settings.battery.bitrates_apply_failed"));
} finally {
this.batteryBitrateBusy = false;
}
},
async restoreBatteryBitrateLimitsNow() {
if (this.batteryBitrateBusy) return;
this.batteryBitrateBusy = true;
try {
const result = await restoreBatterySaverBitrateLimits({ reload: true });
this.loadBatterySaverPrefsFromStorage();
if (result.updated.length === 0) {
ToastUtils.error(this.$t("settings.battery.bitrates_none_restored"));
} else {
ToastUtils.success(this.$t("settings.battery.bitrates_restored", { count: result.updated.length }));
}
await this.loadBatteryInterfaceRows();
} catch (e) {
console.error(e);
ToastUtils.error(this.$t("settings.battery.bitrates_restore_failed"));
} finally {
this.batteryBitrateBusy = false;
}
},
async loadReticulumInstanceSettings() {
try {
const instance = await fetchReticulumInstanceSettings(window.api);

View file

@ -0,0 +1,206 @@
/**
* Lazy-load visualiser-wasm (Go) for network visualiser hot paths.
* Falls back silently when WebAssembly is unavailable or load fails.
* Artifacts live under /vendor/visualiser-wasm/ (built via task build:visualiser-wasm).
*/
let resolvedPromise = null;
let integrityHashes = null;
/** Computes SHA-384 hash of ArrayBuffer for SRI verification. */
async function computeSriHash(buf) {
const hash = await crypto.subtle.digest("SHA-384", buf);
const base64 = btoa(String.fromCharCode(...new Uint8Array(hash)));
return `sha384-${base64}`;
}
/** True when WASM artifacts were present at Vite build time. */
export function isVisualiserWasmBundled() {
if (
typeof globalThis !== "undefined" &&
typeof globalThis.__MESHCHATX_TEST_VISUALISER_WASM_BUNDLED__ === "boolean"
) {
return globalThis.__MESHCHATX_TEST_VISUALISER_WASM_BUNDLED__;
}
return import.meta.env.VITE_VISUALISER_WASM_BUNDLED === "true";
}
function baseUrl() {
const root = import.meta.env.BASE_URL || "/";
return `${root.replace(/\/?$/, "/")}vendor/visualiser-wasm`;
}
async function getIntegrityHashes() {
if (integrityHashes !== null) {
return integrityHashes;
}
const embeddedWasm = typeof __VISUALISER_WASM_SRI_WASM__ !== "undefined" ? __VISUALISER_WASM_SRI_WASM__ : "";
const embeddedExec = typeof __VISUALISER_WASM_SRI_EXEC__ !== "undefined" ? __VISUALISER_WASM_SRI_EXEC__ : "";
if (embeddedWasm && embeddedExec) {
integrityHashes = { wasm: embeddedWasm, wasmExec: embeddedExec };
return integrityHashes;
}
try {
const res = await fetch(`${baseUrl()}/integrity.json`);
if (!res.ok) return null;
integrityHashes = await res.json();
return integrityHashes;
} catch {
return null;
}
}
async function verifySri(buf, expectedHash, name) {
if (!expectedHash) {
throw new Error(`Visualiser WASM: SRI hash missing for ${name}. Refusing to load untrusted code.`);
}
const actualHash = await computeSriHash(buf);
if (actualHash !== expectedHash) {
throw new Error(
`Visualiser WASM: SRI hash mismatch for ${name}. Possible tampering detected. Refusing to execute.`
);
}
}
async function injectScript(src, expectedHash) {
const id = "meshchatx-visualiser-wasm-exec";
if (document.getElementById(id)) {
return;
}
const res = await fetch(src);
if (!res.ok) {
throw new Error(`Visualiser WASM: failed to fetch script ${src} (${res.status})`);
}
const buf = await res.arrayBuffer();
await verifySri(buf, expectedHash, "wasm_exec.js");
const blob = new Blob([buf], { type: "application/javascript" });
const blobUrl = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
const s = document.createElement("script");
s.id = id;
s.async = true;
s.src = blobUrl;
s.onload = () => {
URL.revokeObjectURL(blobUrl);
resolve();
};
s.onerror = () => {
URL.revokeObjectURL(blobUrl);
reject(new Error(`Visualiser WASM: failed to load script ${src}`));
};
document.head.appendChild(s);
});
}
async function instantiateWasmBuffer(buf, go) {
let result;
try {
result = await WebAssembly.instantiateStreaming(
new Response(buf, { headers: { "content-type": "application/wasm" } }),
go.importObject
);
} catch {
result = await WebAssembly.instantiate(buf, go.importObject);
}
go.run(result.instance);
}
function isReady() {
return (
typeof globalThis.meshchatxVisualiserBuildPathGraph === "function" &&
typeof globalThis.meshchatxVisualiserBuildFullGraph === "function" &&
typeof globalThis.meshchatxVisualiserLayout === "function" &&
typeof globalThis.meshchatxVisualiserPathHashes === "function" &&
typeof globalThis.meshchatxVisualiserDedupeIcons === "function"
);
}
async function instantiateOnce() {
if (typeof WebAssembly === "undefined") {
throw new Error("Visualiser WASM: WebAssembly is not available");
}
const root = baseUrl();
const integrity = await getIntegrityHashes();
if (!integrity?.wasmExec) {
throw new Error("Visualiser WASM: wasm_exec SRI missing (build without WASM vendor files?)");
}
if (typeof globalThis.Go === "undefined") {
await injectScript(`${root}/wasm_exec.js`, integrity.wasmExec);
}
if (typeof globalThis.Go === "undefined") {
throw new Error("Visualiser WASM: Go runtime missing after wasm_exec.js load");
}
const go = new globalThis.Go();
const wasmUrl = `${root}/visualiser.wasm`;
const res = await fetch(wasmUrl);
if (!res.ok) {
throw new Error(`Visualiser WASM: fetch failed (${res.status})`);
}
const buf = await res.arrayBuffer();
await verifySri(buf, integrity?.wasm, "visualiser.wasm");
await instantiateWasmBuffer(buf, go);
if (!isReady()) {
throw new Error("Visualiser WASM: exports were not registered");
}
}
/**
* Ensures visualiser WASM is initialized.
* Resolves true when exports are callable, false when unavailable or failed.
*/
export function preloadVisualiserWasm() {
if (!isVisualiserWasmBundled()) {
return Promise.resolve(false);
}
if (isReady()) {
return Promise.resolve(true);
}
if (resolvedPromise === null) {
resolvedPromise = (async () => {
try {
await instantiateOnce();
return isReady();
} catch (e) {
console.warn(e);
resolvedPromise = null;
return false;
}
})();
}
return resolvedPromise;
}
export function isVisualiserWasmReady() {
return isReady();
}
/**
* Call a WASM JSON export and parse the result string.
* Returns null on any failure so callers can fall back to JS.
*/
export function callVisualiserWasmJson(fnName, ...args) {
try {
const fn = globalThis[fnName];
if (typeof fn !== "function") {
return null;
}
const raw = fn(...args);
if (raw == null) {
return null;
}
if (typeof raw === "object" && raw.ok === false) {
console.warn("Visualiser WASM error:", raw.error);
return null;
}
if (typeof raw !== "string") {
return null;
}
return JSON.parse(raw);
} catch (e) {
console.warn("Visualiser WASM call failed:", e);
return null;
}
}

View file

@ -0,0 +1,158 @@
// SPDX-License-Identifier: 0BSD AND MIT
/**
* Identity-scoped IndexedDB cache for the network visualiser.
* Stores path rows, announces, and node positions so reopen can paint quickly
* and only fetch announces for newly seen destination hashes.
*/
const DB_NAME = "meshchatx_visualiser_cache";
const DB_VERSION = 1;
const STORE_NAME = "snapshots";
const CACHE_VERSION = 1;
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
let dbPromise = null;
function getIdb() {
return (
globalThis.indexedDB || globalThis.mozIndexedDB || globalThis.webkitIndexedDB || globalThis.msIndexedDB || null
);
}
function openDb() {
if (dbPromise) {
return dbPromise;
}
const idb = getIdb();
if (!idb) {
return Promise.reject(new Error("IndexedDB unavailable"));
}
dbPromise = new Promise((resolve, reject) => {
const req = idb.open(DB_NAME, DB_VERSION);
req.onerror = () => reject(req.error || new Error("IndexedDB open failed"));
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: "identityHash" });
}
};
req.onsuccess = () => resolve(req.result);
});
return dbPromise;
}
function clonePlain(value) {
return JSON.parse(JSON.stringify(value));
}
/**
* @param {string} identityHash
* @returns {Promise<{ identityHash: string, savedAt: number, pathTable: object[], announces: object, positions: object }|null>}
*/
export async function loadVisualiserCache(identityHash) {
if (!identityHash || typeof identityHash !== "string") {
return null;
}
try {
const db = await openDb();
const row = await new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const req = tx.objectStore(STORE_NAME).get(identityHash);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error);
});
if (!row || row.version !== CACHE_VERSION) {
return null;
}
if (typeof row.savedAt !== "number" || Date.now() - row.savedAt > MAX_AGE_MS) {
return null;
}
if (!Array.isArray(row.pathTable)) {
return null;
}
return {
identityHash: row.identityHash,
savedAt: row.savedAt,
pathTable: row.pathTable,
announces: row.announces && typeof row.announces === "object" ? row.announces : {},
positions: row.positions && typeof row.positions === "object" ? row.positions : {},
};
} catch {
return null;
}
}
/**
* @param {{ identityHash: string, pathTable: object[], announces: object, positions: object, pathSoftCap?: number, announceSoftCap?: number }} snapshot
*/
export async function saveVisualiserCache(snapshot) {
const identityHash = snapshot?.identityHash;
if (!identityHash || typeof identityHash !== "string") {
return false;
}
try {
const pathSoftCap = snapshot.pathSoftCap ?? 20_000;
const announceSoftCap = snapshot.announceSoftCap ?? 10_000;
let pathTable = Array.isArray(snapshot.pathTable) ? snapshot.pathTable : [];
if (pathTable.length > pathSoftCap) {
pathTable = pathTable.slice(0, pathSoftCap);
}
const announcesIn = snapshot.announces && typeof snapshot.announces === "object" ? snapshot.announces : {};
const announceKeys = Object.keys(announcesIn);
let announces = announcesIn;
if (announceKeys.length > announceSoftCap) {
announces = {};
for (const key of announceKeys.slice(0, announceSoftCap)) {
announces[key] = announcesIn[key];
}
}
const positions = snapshot.positions && typeof snapshot.positions === "object" ? snapshot.positions : {};
const row = {
identityHash,
version: CACHE_VERSION,
savedAt: Date.now(),
pathTable: clonePlain(pathTable),
announces: clonePlain(announces),
positions: clonePlain(positions),
};
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.objectStore(STORE_NAME).put(row);
});
return true;
} catch {
return false;
}
}
/**
* @param {string} identityHash
*/
export async function clearVisualiserCache(identityHash) {
if (!identityHash) {
return false;
}
try {
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.objectStore(STORE_NAME).delete(identityHash);
});
return true;
} catch {
return false;
}
}
/** Test helper to reset the open handle between tests. */
export function resetVisualiserCacheDbHandle() {
dbPromise = null;
}

View file

@ -1,5 +1,12 @@
// SPDX-License-Identifier: 0BSD AND MIT
/**
* Network visualiser hot-path helpers.
* Prefers Go WASM when available, otherwise uses the pure JS implementations below.
*/
import { callVisualiserWasmJson, isVisualiserWasmReady, preloadVisualiserWasm } from "./VisualiserWasmLoader.js";
export const VIZ_ANNOUNCE_ASPECTS = ["lxmf.delivery", "nomadnetwork.node"];
export const ANNOUNCE_HASH_CHUNK_SIZE = 500;
@ -15,7 +22,7 @@ export const VIZ_ANNOUNCE_SOFT_CAP = 10_000;
* @param {number|null|undefined} hopMax
* @returns {string[]}
*/
export function pathHashesWithinHopFilter(pathTable, hopMax) {
export function pathHashesWithinHopFilterJs(pathTable, hopMax) {
if (!Array.isArray(pathTable) || pathTable.length === 0) {
return [];
}
@ -39,12 +46,31 @@ export function pathHashesWithinHopFilter(pathTable, hopMax) {
return Array.from(out);
}
/**
* @param {unknown[]} pathTable
* @param {number|null|undefined} hopMax
* @returns {string[]}
*/
export function pathHashesWithinHopFilter(pathTable, hopMax) {
if (isVisualiserWasmReady()) {
const got = callVisualiserWasmJson(
"meshchatxVisualiserPathHashes",
JSON.stringify(pathTable),
hopMax == null ? null : hopMax
);
if (Array.isArray(got)) {
return got;
}
}
return pathHashesWithinHopFilterJs(pathTable, hopMax);
}
/**
* Collapse deferred icon work so each unique cacheKey is painted once.
* @param {unknown[]} queue
* @returns {{ cacheKey: string, nodeIds: string[], iconName: string, fg: string, bg: string, size: number, generation: number }[]}
*/
export function dedupeIconQueueEntries(queue) {
export function dedupeIconQueueEntriesJs(queue) {
if (!Array.isArray(queue) || queue.length === 0) {
return [];
}
@ -77,6 +103,20 @@ export function dedupeIconQueueEntries(queue) {
return Array.from(byKey.values());
}
/**
* @param {unknown[]} queue
* @returns {{ cacheKey: string, nodeIds: string[], iconName: string, fg: string, bg: string, size: number, generation: number }[]}
*/
export function dedupeIconQueueEntries(queue) {
if (isVisualiserWasmReady()) {
const got = callVisualiserWasmJson("meshchatxVisualiserDedupeIcons", JSON.stringify(queue));
if (Array.isArray(got)) {
return got;
}
}
return dedupeIconQueueEntriesJs(queue);
}
/**
* Parallel path/announce fetch concurrency scaled to hardware.
* @returns {number}
@ -88,3 +128,376 @@ export function pickAdaptiveFetchConcurrency() {
if (cores <= 6) return 4;
return 6;
}
/** FNV-1a style deterministic unit fraction for stable layout without Math.random. */
function hash01(id, salt = "") {
let h = 2166136261;
const s = String(id) + "\0" + String(salt);
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return ((h >>> 0) % 10000) / 10000;
}
function hashAngle(id) {
return hash01(id) * Math.PI * 2;
}
function nodeColor(border, background) {
return {
border,
background,
highlight: { border, background },
hover: { border, background },
};
}
function edgeColor(direct, darkMode) {
if (direct) {
return { color: darkMode ? "#34d399" : "#10b981", opacity: 1 };
}
return { color: darkMode ? "#60a5fa" : "#3b82f6", opacity: 0.5 };
}
function applyLodToAnnounceNode(node, lod, fontColor) {
if (lod === "low") {
node.shape = "dot";
node.size = node.id === "me" ? 15 : 10;
node.font = { size: 0 };
return;
}
if (lod === "medium") {
node.shape = node._originalShape || "circularImage";
node.size = node._originalSize || 25;
node.font = { size: 0 };
return;
}
node.shape = node._originalShape || "circularImage";
node.size = node._originalSize || 25;
node.font = { size: node.id === "me" ? 16 : 11, color: fontColor };
}
/**
* Pure JS path-table graph builder (fallback when WASM is unavailable).
* @param {object} req
* @returns {{ nodes: object[], edges: object[], icon_queue: object[], processed_node_ids: string[], processed_edge_ids: string[] }}
*/
export function buildPathGraphJs(req) {
const pathTable = Array.isArray(req?.path_table) ? req.path_table : [];
const announces = req?.announces && typeof req.announces === "object" ? req.announces : {};
const conversations = req?.conversations && typeof req.conversations === "object" ? req.conversations : {};
const iconCache = req?.icon_cache && typeof req.icon_cache === "object" ? req.icon_cache : {};
const positions = { ...(req?.positions && typeof req.positions === "object" ? req.positions : {}) };
const hopMax = req?.hop_max;
const searchLower = String(req?.search || "").toLowerCase();
const darkMode = !!req?.dark_mode;
const lod = req?.lod || "high";
const aspects = Array.isArray(req?.aspects) && req.aspects.length > 0 ? req.aspects : VIZ_ANNOUNCE_ASPECTS;
const aspectSet = new Set(aspects);
const queueIcons = !!req?.queue_icons;
const iconGeneration = req?.icon_generation || 0;
const fontColor = darkMode ? "#ffffff" : "#000000";
const matchesSearch = (text) => !searchLower || (text && String(text).toLowerCase().includes(searchLower));
const nodes = [];
const edges = [];
const iconQueue = [];
const processedNodeIds = [];
const processedEdgeIds = [];
for (const entry of pathTable) {
if (!entry || entry.hops == null || !entry.hash) continue;
if (hopMax != null && entry.hops > hopMax) continue;
const announce = announces[entry.hash];
if (!announce || !aspectSet.has(announce.aspect)) continue;
const displayName = announce.custom_display_name ?? announce.display_name;
if (
!matchesSearch(displayName) &&
!matchesSearch(announce.destination_hash) &&
!matchesSearch(announce.identity_hash)
) {
continue;
}
let x;
let y;
const prev = positions[entry.hash];
if (prev && Number.isFinite(prev.x) && Number.isFinite(prev.y)) {
x = prev.x;
y = prev.y;
} else {
const ip = positions[entry.interface];
const angle = hashAngle(entry.hash);
if (ip && Number.isFinite(ip.x) && Number.isFinite(ip.y)) {
const dist = 150 + hash01(entry.hash, "r") * 150;
x = ip.x + Math.cos(angle) * dist;
y = ip.y + Math.sin(angle) * dist;
} else {
const dist = 600 + hash01(entry.hash, "r") * 200;
x = Math.cos(angle) * dist;
y = Math.sin(angle) * dist;
}
positions[entry.hash] = { x, y };
}
const edgeId = `${entry.interface}~${entry.hash}`;
const direct = entry.hops === 1;
const conversation = conversations[announce.destination_hash];
let node = {
id: entry.hash,
group: "announce",
size: 25,
_originalSize: 25,
font: { color: fontColor, size: 11 },
x,
y,
label: displayName,
title: `${displayName}\nAspect: ${announce.aspect}\nHops: ${entry.hops}\nVia: ${entry.interface}\nLast Seen: ${announce.last_seen || ""}`,
_parentInterface: entry.interface,
};
if (announce.aspect === "lxmf.delivery") {
node.shape = "circularImage";
node._originalShape = "circularImage";
if (conversation?.lxmf_user_icon) {
const ic = conversation.lxmf_user_icon;
const cacheKey = `${ic.icon_name}-${ic.foreground_colour}-${ic.background_colour}-64`;
if (iconCache[cacheKey]) {
node.image = iconCache[cacheKey];
} else {
node.image = direct
? "/assets/images/network-visualiser/user_1hop.png"
: "/assets/images/network-visualiser/user.png";
if (queueIcons) {
iconQueue.push({
nodeId: node.id,
cacheKey,
iconName: ic.icon_name,
fg: ic.foreground_colour,
bg: ic.background_colour,
size: 64,
generation: iconGeneration,
});
}
}
node.size = 30;
node._originalSize = 30;
} else {
node.image = direct
? "/assets/images/network-visualiser/user_1hop.png"
: "/assets/images/network-visualiser/user.png";
}
node.color = nodeColor(
direct ? "#10b981" : "#3b82f6",
direct ? (darkMode ? "#064e3b" : "#ecfdf5") : darkMode ? "#1e40af" : "#eff6ff"
);
} else if (announce.aspect === "nomadnetwork.node") {
node.shape = "circularImage";
node._originalShape = "circularImage";
node.image = direct
? "/assets/images/network-visualiser/server_1hop.png"
: "/assets/images/network-visualiser/server.png";
node.color = nodeColor(
direct ? "#10b981" : "#8b5cf6",
direct ? (darkMode ? "#064e3b" : "#ecfdf5") : darkMode ? "#4c1d95" : "#f5f3ff"
);
}
applyLodToAnnounceNode(node, lod, fontColor);
nodes.push(node);
processedNodeIds.push(node.id);
edges.push({
id: edgeId,
from: entry.interface,
to: entry.hash,
color: edgeColor(direct, darkMode),
width: direct ? 2.5 : 1,
hidden: false,
});
processedEdgeIds.push(edgeId);
}
return {
nodes,
edges,
icon_queue: iconQueue,
processed_node_ids: processedNodeIds,
processed_edge_ids: processedEdgeIds,
};
}
/**
* Build announce nodes/edges from the path table via WASM or JS fallback.
* @param {object} req
*/
export function buildPathGraph(req) {
if (isVisualiserWasmReady()) {
const got = callVisualiserWasmJson("meshchatxVisualiserBuildPathGraph", JSON.stringify(req));
if (got && Array.isArray(got.nodes) && Array.isArray(got.edges)) {
return got;
}
}
return buildPathGraphJs(req);
}
/**
* Full mesh graph (me + interfaces + discovered + announces) via WASM or JS.
* @param {object} req
*/
export function buildFullGraph(req) {
if (isVisualiserWasmReady()) {
const got = callVisualiserWasmJson("meshchatxVisualiserBuildFullGraph", JSON.stringify(req));
if (got && Array.isArray(got.nodes) && Array.isArray(got.edges)) {
return got;
}
}
// Fallback: path graph only, caller still builds me/ifaces in Vue for older paths.
const path = buildPathGraphJs({
path_table: req.path_table,
announces: req.announces,
conversations: req.conversations,
icon_cache: req.icon_cache,
positions: req.positions,
hop_max: req.hop_max,
search: req.search,
dark_mode: req.dark_mode,
lod: req.lod,
aspects: req.aspects,
queue_icons: req.queue_icons,
icon_generation: req.icon_generation,
});
return {
...path,
layout_nodes: (path.nodes || []).map((n) => ({
id: n.id,
x: n.x,
y: n.y,
mass: n.group === "me" ? 4 : n.group === "interface" ? 2.5 : 1,
fixed: n.id === "me",
})),
layout_edges: (path.edges || []).map((e) => ({
from: e.from,
to: e.to,
length: e.width >= 2 ? 150 : 180,
})),
};
}
/**
* Settle layout positions in WASM (or no-op passthrough when unavailable).
* @param {{ nodes: object[], edges: object[], iterations?: number }} req
*/
export function settleLayout(req) {
if (isVisualiserWasmReady()) {
const got = callVisualiserWasmJson("meshchatxVisualiserLayout", JSON.stringify(req));
if (got && got.positions && typeof got.positions === "object") {
return got;
}
}
const positions = {};
for (const n of req?.nodes || []) {
if (n?.id) positions[n.id] = { x: n.x || 0, y: n.y || 0 };
}
return { positions, iterations: 0 };
}
/**
* @param {object[]} nodes
* @param {string} lod
* @param {boolean} darkMode
* @returns {object[]}
*/
export function computeLodUpdatesJs(nodes, lod, darkMode) {
if (!Array.isArray(nodes) || nodes.length === 0) {
return [];
}
const fontColor = darkMode ? "#ffffff" : "#000000";
const blueBorder = "#3b82f6";
const blueBg = darkMode ? "#1e40af" : "#eff6ff";
const updates = [];
for (const node of nodes) {
if (!node || !node.id) continue;
let next;
if (lod === "low") {
const isInterface = node.group === "interface";
const baseColor = isInterface && node.color ? node.color : nodeColor(blueBorder, blueBg);
next = {
id: node.id,
shape: "dot",
size: node.id === "me" ? 15 : 10,
font: { size: 0 },
color: baseColor,
};
} else if (lod === "medium") {
next = {
id: node.id,
shape: node._originalShape || "circularImage",
size: node._originalSize || (node.id === "me" ? 50 : 25),
font: { size: 0 },
};
} else {
next = {
id: node.id,
shape: node._originalShape || "circularImage",
size: node._originalSize || (node.id === "me" ? 50 : 25),
font: { size: node.id === "me" ? 16 : 11, color: fontColor },
};
}
const shapeChanged = next.shape != null && next.shape !== node.shape;
const sizeChanged = next.size != null && next.size !== node.size;
const fontSize = next.font?.size;
const fontChanged = fontSize != null && fontSize !== (node.font?.size ?? null);
if (shapeChanged || sizeChanged || fontChanged) {
updates.push(next);
}
}
return updates;
}
/**
* @param {object[]} nodes
* @param {string} lod
* @param {boolean} darkMode
*/
export function computeLodUpdates(nodes, lod, darkMode) {
if (isVisualiserWasmReady()) {
const got = callVisualiserWasmJson(
"meshchatxVisualiserLODUpdates",
JSON.stringify({ nodes, lod, dark_mode: !!darkMode })
);
if (Array.isArray(got)) {
return got;
}
}
return computeLodUpdatesJs(nodes, lod, darkMode);
}
/**
* Map camera scale to LOD level (WASM when ready).
* @param {number} scale
* @returns {"low"|"medium"|"high"}
*/
export function lodLevelFromScale(scale) {
if (isVisualiserWasmReady() && typeof globalThis.meshchatxVisualiserLODLevel === "function") {
try {
const v = globalThis.meshchatxVisualiserLODLevel(scale);
if (v === "low" || v === "medium" || v === "high") {
return v;
}
} catch {
/* fall through */
}
}
if (scale < 0.2) return "low";
if (scale < 0.5) return "medium";
return "high";
}
/** Warm the WASM module early. Safe to ignore the result. */
export function warmVisualiserWasm() {
return preloadVisualiserWasm().catch(() => false);
}

View file

@ -202,6 +202,25 @@ export const CORE_SETTINGS_SECTION_KEYWORDS = {
"app.live_preview",
"app.realtime",
],
battery: [
"Battery",
"Battery saver",
"power",
"polling",
"discovery",
"settings.battery.title",
"settings.battery.description",
"settings.battery.enabled",
"settings.battery.disable_visualiser_discovery",
"settings.battery.hide_offline_interfaces",
"settings.battery.max_visualiser_interfaces",
"settings.battery.visualiser_reload_seconds",
"settings.battery.disable_visualiser_live_layout",
"settings.battery.reduce_background_polling",
"settings.battery.reduce_interfaces_discovery",
"settings.battery.apply_interface_bitrate_limits",
"settings.battery.apply_bitrates_reload",
],
language: [
"i18n",
"app.language",

View file

@ -0,0 +1,55 @@
// SPDX-License-Identifier: 0BSD AND MIT
/**
* Merge backend resource_breakdown with optional Electron private memory.
* Electron processMemoryInfo values are kilobytes.
*
* @param {Array<{name?: string, rss?: number|null, cpu_percent?: number|null}>|null|undefined} breakdown
* @param {{ private?: number, residentSet?: number }|null|undefined} electronMemory
* @returns {Array<{name: string, rss: number|null, cpu_percent: number|null}>}
*/
export function mergeResourceBreakdown(breakdown, electronMemory) {
const rows = Array.isArray(breakdown)
? breakdown
.filter((row) => row && typeof row === "object")
.map((row) => ({
name: String(row.name || "process"),
rss: row.rss == null ? null : Number(row.rss),
cpu_percent: row.cpu_percent == null ? null : Number(row.cpu_percent),
}))
: [];
if (electronMemory && typeof electronMemory === "object") {
const kb = Number(electronMemory.private ?? electronMemory.residentSet);
if (Number.isFinite(kb) && kb > 0) {
rows.push({
name: "electron",
rss: Math.round(kb * 1024),
cpu_percent: null,
});
}
}
return rows;
}
/**
* @param {Array<{name: string, rss: number|null, cpu_percent: number|null}>} rows
* @returns {{name: string, rss: number|null, cpu_percent: number|null}|null}
*/
export function topResourceByRss(rows) {
if (!Array.isArray(rows) || rows.length === 0) return null;
const scored = rows.filter((r) => r.rss != null && Number.isFinite(r.rss));
if (!scored.length) return null;
return scored.reduce((best, row) => (row.rss > best.rss ? row : best));
}
/**
* @param {Array<{name: string, rss: number|null, cpu_percent: number|null}>} rows
* @returns {{name: string, rss: number|null, cpu_percent: number|null}|null}
*/
export function topResourceByCpu(rows) {
if (!Array.isArray(rows) || rows.length === 0) return null;
const scored = rows.filter((r) => r.cpu_percent != null && Number.isFinite(r.cpu_percent));
if (!scored.length) return null;
return scored.reduce((best, row) => (row.cpu_percent > best.cpu_percent ? row : best));
}

View file

@ -0,0 +1,72 @@
// SPDX-License-Identifier: 0BSD AND MIT
import { buildBitrateApplyPayload, loadBatterySaverPrefs, saveBatterySaverPrefs } from "./batterySaverPrefs.js";
/**
* Apply configured battery-saver bitrate caps and optionally reload RNS.
* @param {{ api?: { get: Function, post: Function }, reload?: boolean }} [opts]
* @returns {Promise<{ updated: string[], reloaded: boolean }>}
*/
export async function applyBatterySaverBitrateLimits(opts = {}) {
const api = opts.api || (typeof window !== "undefined" ? window.api : null);
if (!api) {
throw new Error("API client unavailable");
}
const prefs = loadBatterySaverPrefs();
if (!prefs.applyInterfaceBitrateLimits) {
return { updated: [], reloaded: false };
}
const limits = prefs.interfaceBitrateLimits || {};
if (Object.keys(limits).length === 0) {
return { updated: [], reloaded: false };
}
const listResp = await api.get("/api/v1/reticulum/interfaces");
const interfaces = listResp?.data?.interfaces || {};
const { bitrates, previous } = buildBitrateApplyPayload(interfaces, limits);
if (Object.keys(bitrates).length === 0) {
return { updated: [], reloaded: false };
}
const reload = opts.reload !== false;
const resp = await api.post("/api/v1/reticulum/interfaces/bitrates", {
bitrates,
reload,
});
saveBatterySaverPrefs({
interfaceBitratePrevious: {
...prefs.interfaceBitratePrevious,
...previous,
},
});
return {
updated: resp?.data?.updated || Object.keys(bitrates),
reloaded: Boolean(resp?.data?.reloaded),
};
}
/**
* Restore bitrates saved before the last apply, then reload RNS.
* @param {{ api?: { post: Function }, reload?: boolean }} [opts]
*/
export async function restoreBatterySaverBitrateLimits(opts = {}) {
const api = opts.api || (typeof window !== "undefined" ? window.api : null);
if (!api) {
throw new Error("API client unavailable");
}
const prefs = loadBatterySaverPrefs();
const previous = prefs.interfaceBitratePrevious || {};
if (Object.keys(previous).length === 0) {
return { updated: [], reloaded: false };
}
const reload = opts.reload !== false;
const resp = await api.post("/api/v1/reticulum/interfaces/bitrates", {
bitrates: previous,
reload,
});
saveBatterySaverPrefs({ interfaceBitratePrevious: {} });
return {
updated: resp?.data?.updated || Object.keys(previous),
reloaded: Boolean(resp?.data?.reloaded),
};
}

View file

@ -0,0 +1,239 @@
// SPDX-License-Identifier: 0BSD AND MIT
/**
* Battery saver preferences (localStorage).
* Safe UI/runtime throttles plus optional forced interface bitrates.
*/
import GlobalEmitter from "../GlobalEmitter";
export const BATTERY_SAVER_STORAGE_KEY = "meshchatx.batterySaver";
export const BATTERY_SAVER_CHANGED_EVENT = "battery-saver-prefs-changed";
/** @typedef {object} BatterySaverPrefs
* @property {boolean} enabled
* @property {boolean} disableVisualiserDiscovery
* @property {boolean} hideOfflineInterfaces
* @property {number} maxVisualiserInterfaces 0 = unlimited
* @property {number} visualiserReloadSeconds 0 = disable auto-reload while saver on
* @property {boolean} disableVisualiserLiveLayout
* @property {boolean} reduceBackgroundPolling
* @property {number} backgroundPollMultiplier
* @property {boolean} reduceInterfacesDiscovery
* @property {number} interfacesStatsPollSeconds
* @property {number} interfacesDiscoveryPollSeconds
* @property {boolean} applyInterfaceBitrateLimits
* @property {Record<string, number>} interfaceBitrateLimits name -> bps
* @property {Record<string, number|null>} interfaceBitratePrevious snapshot before apply
*/
/** @type {BatterySaverPrefs} */
export const BATTERY_SAVER_DEFAULTS = Object.freeze({
enabled: false,
disableVisualiserDiscovery: true,
hideOfflineInterfaces: true,
maxVisualiserInterfaces: 8,
visualiserReloadSeconds: 60,
disableVisualiserLiveLayout: true,
reduceBackgroundPolling: true,
backgroundPollMultiplier: 3,
reduceInterfacesDiscovery: true,
interfacesStatsPollSeconds: 5,
interfacesDiscoveryPollSeconds: 30,
applyInterfaceBitrateLimits: false,
interfaceBitrateLimits: Object.freeze({}),
interfaceBitratePrevious: Object.freeze({}),
});
/**
* @param {unknown} raw
* @returns {Record<string, number>}
*/
export function normalizeBitrateLimitsMap(raw) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
/** @type {Record<string, number>} */
const out = {};
for (const [name, value] of Object.entries(raw)) {
const key = String(name || "").trim();
if (!key) continue;
const bps = Number(value);
if (!Number.isFinite(bps) || bps < 0) continue;
out[key] = Math.round(bps);
}
return out;
}
/**
* @param {unknown} raw
* @returns {Record<string, number|null>}
*/
export function normalizeBitratePreviousMap(raw) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
/** @type {Record<string, number|null>} */
const out = {};
for (const [name, value] of Object.entries(raw)) {
const key = String(name || "").trim();
if (!key) continue;
if (value == null || value === "") {
out[key] = null;
continue;
}
const bps = Number(value);
if (!Number.isFinite(bps) || bps < 0) continue;
out[key] = Math.round(bps);
}
return out;
}
/**
* @param {unknown} raw
* @returns {BatterySaverPrefs}
*/
export function normalizeBatterySaverPrefs(raw) {
const src = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
const mult = Number(src.backgroundPollMultiplier);
const maxIfaces = Number(src.maxVisualiserInterfaces);
const vizReload = Number(src.visualiserReloadSeconds);
const statsPoll = Number(src.interfacesStatsPollSeconds);
const discPoll = Number(src.interfacesDiscoveryPollSeconds);
return {
enabled: src.enabled === true,
disableVisualiserDiscovery: src.disableVisualiserDiscovery !== false,
hideOfflineInterfaces: src.hideOfflineInterfaces !== false,
maxVisualiserInterfaces: Number.isFinite(maxIfaces)
? Math.max(0, Math.min(128, Math.round(maxIfaces)))
: BATTERY_SAVER_DEFAULTS.maxVisualiserInterfaces,
visualiserReloadSeconds: Number.isFinite(vizReload)
? Math.max(0, Math.min(600, Math.round(vizReload)))
: BATTERY_SAVER_DEFAULTS.visualiserReloadSeconds,
disableVisualiserLiveLayout: src.disableVisualiserLiveLayout !== false,
reduceBackgroundPolling: src.reduceBackgroundPolling !== false,
backgroundPollMultiplier: Number.isFinite(mult)
? Math.max(2, Math.min(10, Math.round(mult)))
: BATTERY_SAVER_DEFAULTS.backgroundPollMultiplier,
reduceInterfacesDiscovery: src.reduceInterfacesDiscovery !== false,
interfacesStatsPollSeconds: Number.isFinite(statsPoll)
? Math.max(1, Math.min(120, Math.round(statsPoll)))
: BATTERY_SAVER_DEFAULTS.interfacesStatsPollSeconds,
interfacesDiscoveryPollSeconds: Number.isFinite(discPoll)
? Math.max(5, Math.min(300, Math.round(discPoll)))
: BATTERY_SAVER_DEFAULTS.interfacesDiscoveryPollSeconds,
applyInterfaceBitrateLimits: src.applyInterfaceBitrateLimits === true,
interfaceBitrateLimits: normalizeBitrateLimitsMap(src.interfaceBitrateLimits),
interfaceBitratePrevious: normalizeBitratePreviousMap(src.interfaceBitratePrevious),
};
}
/**
* @returns {BatterySaverPrefs}
*/
export function loadBatterySaverPrefs() {
try {
if (typeof localStorage === "undefined") {
return { ...BATTERY_SAVER_DEFAULTS, interfaceBitrateLimits: {}, interfaceBitratePrevious: {} };
}
const raw = localStorage.getItem(BATTERY_SAVER_STORAGE_KEY);
if (raw == null || raw === "") {
return { ...BATTERY_SAVER_DEFAULTS, interfaceBitrateLimits: {}, interfaceBitratePrevious: {} };
}
return normalizeBatterySaverPrefs(JSON.parse(raw));
} catch {
return { ...BATTERY_SAVER_DEFAULTS, interfaceBitrateLimits: {}, interfaceBitratePrevious: {} };
}
}
/**
* @param {Partial<BatterySaverPrefs>} patch
* @returns {BatterySaverPrefs}
*/
export function saveBatterySaverPrefs(patch) {
const next = normalizeBatterySaverPrefs({ ...loadBatterySaverPrefs(), ...patch });
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(BATTERY_SAVER_STORAGE_KEY, JSON.stringify(next));
}
} catch {
/* ignore */
}
GlobalEmitter.emit(BATTERY_SAVER_CHANGED_EVENT, next);
return next;
}
/**
* @param {number} baseMs
* @param {BatterySaverPrefs} [prefs]
* @returns {number}
*/
export function applyBackgroundPollInterval(baseMs, prefs = loadBatterySaverPrefs()) {
const base = Number(baseMs);
if (!Number.isFinite(base) || base <= 0) {
return baseMs;
}
if (!prefs.enabled || !prefs.reduceBackgroundPolling) {
return base;
}
return Math.round(base * prefs.backgroundPollMultiplier);
}
/**
* Effective visualiser auto-reload interval in ms, or null to disable.
* @param {number} defaultMs
* @param {BatterySaverPrefs} [prefs]
* @returns {number|null}
*/
export function effectiveVisualiserReloadMs(defaultMs, prefs = loadBatterySaverPrefs()) {
if (!prefs.enabled) {
return defaultMs;
}
if (prefs.visualiserReloadSeconds <= 0) {
return null;
}
return prefs.visualiserReloadSeconds * 1000;
}
/**
* @param {BatterySaverPrefs} [prefs]
* @returns {string[]}
*/
export function activeBatterySaverMeasures(prefs = loadBatterySaverPrefs()) {
if (!prefs.enabled) {
return [];
}
const out = [];
if (prefs.disableVisualiserDiscovery) out.push("disableVisualiserDiscovery");
if (prefs.hideOfflineInterfaces) out.push("hideOfflineInterfaces");
if (prefs.maxVisualiserInterfaces > 0) out.push("maxVisualiserInterfaces");
if (prefs.visualiserReloadSeconds === 0) out.push("disableVisualiserAutoReload");
else if (prefs.visualiserReloadSeconds > 15) out.push("slowVisualiserReload");
if (prefs.disableVisualiserLiveLayout) out.push("disableVisualiserLiveLayout");
if (prefs.reduceBackgroundPolling) out.push("reduceBackgroundPolling");
if (prefs.reduceInterfacesDiscovery) out.push("reduceInterfacesDiscovery");
if (prefs.applyInterfaceBitrateLimits && Object.keys(prefs.interfaceBitrateLimits || {}).length > 0) {
out.push("applyInterfaceBitrateLimits");
}
return out;
}
/**
* @param {Record<string, any>} interfacesMap
* @param {Record<string, number>} limits
* @returns {{ bitrates: Record<string, number>, previous: Record<string, number|null> }}
*/
export function buildBitrateApplyPayload(interfacesMap, limits) {
const bitrates = {};
const previous = {};
const src = interfacesMap && typeof interfacesMap === "object" ? interfacesMap : {};
const lim = normalizeBitrateLimitsMap(limits);
for (const [name, bps] of Object.entries(lim)) {
if (!(name in src)) continue;
const current = src[name]?.bitrate;
const parsed = current == null || current === "" ? null : Number(current);
previous[name] = Number.isFinite(parsed) ? Math.round(parsed) : null;
bitrates[name] = bps;
}
return { bitrates, previous };
}

View file

@ -8,7 +8,7 @@ export const SETTINGS_TABS = [
id: "general",
labelKey: "settings.tabs.general",
descriptionKey: "settings.tabs.general_desc",
sections: ["language", "appearance", "desktop", "android", "shortcuts", "location"],
sections: ["language", "appearance", "battery", "desktop", "android", "shortcuts", "location"],
},
{
id: "messages",

View file

@ -1,25 +1,62 @@
// SPDX-License-Identifier: 0BSD AND MIT
import GlobalEmitter from "../GlobalEmitter";
const KEY_DISABLED = "meshchatx.visualiser.showDisabledInterfaces";
const KEY_DISCOVERED = "meshchatx.visualiser.showDiscoveredInterfaces";
const KEY_LIVE_LAYOUT = "meshchatx.visualiser.enablePhysics";
const KEY_AUTO_RELOAD = "meshchatx.visualiser.autoReload";
export const VISUALISER_DISPLAY_PREFS_CHANGED = "visualiser-display-prefs-changed";
/**
* @returns {{ showDisabledInterfaces: boolean, showDiscoveredInterfaces: boolean }}
* @param {string} key
* @param {boolean} defaultValue
* @returns {boolean}
*/
export function loadVisualiserDisplayPrefs() {
function readBool(key, defaultValue) {
try {
if (typeof localStorage !== "undefined") {
return {
showDisabledInterfaces: localStorage.getItem(KEY_DISABLED) === "true",
showDiscoveredInterfaces: localStorage.getItem(KEY_DISCOVERED) === "true",
};
if (typeof localStorage === "undefined") {
return defaultValue;
}
const raw = localStorage.getItem(key);
if (raw === "true") return true;
if (raw === "false") return false;
} catch {
/* localStorage unavailable */
}
return defaultValue;
}
/**
* @param {string} key
* @param {boolean} val
*/
function writeBool(key, val) {
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(key, val ? "true" : "false");
}
} catch {
/* ignore */
}
}
/**
* @returns {{
* showDisabledInterfaces: boolean,
* showDiscoveredInterfaces: boolean,
* enablePhysics: boolean,
* autoReload: boolean,
* }}
*/
export function loadVisualiserDisplayPrefs() {
return {
showDisabledInterfaces: false,
showDiscoveredInterfaces: false,
showDisabledInterfaces: readBool(KEY_DISABLED, false),
showDiscoveredInterfaces: readBool(KEY_DISCOVERED, false),
// Live Layout defaults on when never set.
enablePhysics: readBool(KEY_LIVE_LAYOUT, true),
autoReload: readBool(KEY_AUTO_RELOAD, false),
};
}
@ -27,26 +64,36 @@ export function loadVisualiserDisplayPrefs() {
* @param {boolean} val
*/
export function persistVisualiserShowDisabled(val) {
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(KEY_DISABLED, val ? "true" : "false");
}
} catch {
/* ignore */
}
GlobalEmitter.emit("visualiser-display-prefs-changed");
writeBool(KEY_DISABLED, val === true);
GlobalEmitter.emit(VISUALISER_DISPLAY_PREFS_CHANGED);
}
/**
* @param {boolean} val
*/
export function persistVisualiserShowDiscovered(val) {
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(KEY_DISCOVERED, val ? "true" : "false");
}
} catch {
/* ignore */
}
GlobalEmitter.emit("visualiser-display-prefs-changed");
writeBool(KEY_DISCOVERED, val === true);
GlobalEmitter.emit(VISUALISER_DISPLAY_PREFS_CHANGED);
}
/**
* @param {boolean} val
* @param {{ emit?: boolean }} [opts]
*/
export function persistVisualiserLiveLayout(val, opts = {}) {
writeBool(KEY_LIVE_LAYOUT, val === true);
if (opts.emit !== false) {
GlobalEmitter.emit(VISUALISER_DISPLAY_PREFS_CHANGED);
}
}
/**
* @param {boolean} val
* @param {{ emit?: boolean }} [opts]
*/
export function persistVisualiserAutoReload(val, opts = {}) {
writeBool(KEY_AUTO_RELOAD, val === true);
if (opts.emit !== false) {
GlobalEmitter.emit(VISUALISER_DISPLAY_PREFS_CHANGED);
}
}

View file

@ -1195,7 +1195,7 @@
"process_threads": "Threads",
"process_uptime": "Laufzeit",
"memory_pressure": "Speicherdruck",
"memory_pressure_relaxed": "Entspannt (wenig Speicher)",
"memory_pressure_relaxed": "Active (low host RAM)",
"memory_pressure_paths": "{count} Pfade verfolgt",
"env_host_battery": "Host-Akku",
"app_battery_use": "Gesch. MeshChatX-Akku",
@ -1208,7 +1208,27 @@
"app_battery_intensity_low": "niedrig",
"app_battery_intensity_moderate": "mittel",
"app_battery_intensity_high": "hoch",
"app_battery_intensity_very_high": "sehr hoch"
"app_battery_intensity_very_high": "sehr hoch",
"battery_saver": "Batteriesparmodus",
"battery_saver_off": "Aus",
"battery_saver_on": "An",
"battery_saver_measures": "Aktive Massnahmen",
"battery_saver_measure": {
"disableVisualiserDiscovery": "Visualiser discovery hidden",
"hideOfflineInterfaces": "Offline interfaces hidden",
"maxVisualiserInterfaces": "Visualiser interface limit",
"disableVisualiserAutoReload": "Visualiser auto-reload off",
"slowVisualiserReload": "Slower visualiser reload",
"disableVisualiserLiveLayout": "Live layout off",
"reduceBackgroundPolling": "Slower background polling",
"reduceInterfacesDiscovery": "Slower interface discovery polls",
"applyInterfaceBitrateLimits": "Forced interface bitrates"
},
"top_memory_consumer": "Top memory",
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
"path_table_count": "{count} paths"
},
"interfaces": {
"title": "Schnittstellen",
@ -3214,7 +3234,46 @@
},
"failed_update_reticulum_instance": "Fehler beim Aktualisieren der Reticulum-Instanzeinstellungen!",
"keyboard_shortcuts_title": "Tastaturkürzel",
"keyboard_shortcuts_description": "Schnelle Tastaturaktionen anpassen. Auf Handys standardmäßig eingeklappt."
"keyboard_shortcuts_description": "Schnelle Tastaturaktionen anpassen. Auf Handys standardmäßig eingeklappt.",
"battery": {
"eyebrow": "Power",
"title": "Battery saver",
"description": "Reduce UI polling and visualiser work on battery. Messaging and mesh delivery stay active. Each option below applies only while battery saver is on.",
"enabled": "Enable battery saver",
"enabled_desc": "Master switch for the power-saving options below.",
"options_heading": "When battery saver is on",
"disable_visualiser_discovery": "Hide discovered interfaces in visualiser",
"disable_visualiser_discovery_desc": "Stops drawing discovered-interface nodes on the network graph.",
"hide_offline_interfaces": "Hide offline interfaces in visualiser",
"hide_offline_interfaces_desc": "Only show online (and path-table) interfaces on the graph.",
"max_visualiser_interfaces": "Max interfaces on visualiser",
"max_visualiser_interfaces_desc": "0 means unlimited. Prefer online interfaces when truncating.",
"visualiser_reload_seconds": "Visualiser auto-reload interval (seconds)",
"visualiser_reload_seconds_desc": "0 disables auto-reload while saver is on. Default without saver is 15s.",
"disable_visualiser_live_layout": "Disable visualiser live layout",
"disable_visualiser_live_layout_desc": "Turns off continuous layout/physics work on the graph.",
"reduce_background_polling": "Slow background status polling",
"reduce_background_polling_desc": "Multiplies App shell poll intervals (calls, unread, app info).",
"background_poll_multiplier": "Background poll multiplier",
"background_poll_multiplier_desc": "210. Applied to App shell intervals when the option above is on.",
"reduce_interfaces_discovery": "Slow Interfaces page polls",
"reduce_interfaces_discovery_desc": "Uses the intervals below for stats and discovered-interface refresh.",
"interfaces_stats_poll_seconds": "Interfaces stats poll (seconds)",
"interfaces_discovery_poll_seconds": "Discovered interfaces poll (seconds)",
"apply_interface_bitrate_limits": "Force interface bitrates",
"apply_interface_bitrate_limits_desc": "Write forced bitrate (bps) into selected Reticulum interfaces and reload RNS when applied.",
"interface_bitrate_limits_help": "Enter a bitrate in bits per second for each interface you want to cap. Leave blank to skip. Apply writes the config and reloads Reticulum.",
"interface_bitrate_limits_empty": "No interfaces found yet.",
"interface_bitrate_placeholder": "bps",
"apply_bitrates_reload": "Apply bitrates and reload RNS",
"restore_bitrates_reload": "Restore previous bitrates and reload",
"bitrates_applied": "Updated {count} interface bitrate(s) and reloaded RNS",
"bitrates_restored": "Restored {count} interface bitrate(s) and reloaded RNS",
"bitrates_none_applied": "No bitrate limits matched current interfaces",
"bitrates_none_restored": "No previous bitrates to restore",
"bitrates_apply_failed": "Failed to apply interface bitrates",
"bitrates_restore_failed": "Failed to restore interface bitrates"
}
},
"debug": {
"title": "Debug-Protokolle",
@ -3288,7 +3347,25 @@
"failed_load": "Netzwerkdaten laden fehlgeschlagen",
"max_hops_filter": "Max. Hops",
"show_disabled_interfaces": "Offline-Interfaces anzeigen",
"show_discovered_interfaces": "Entdeckte Interfaces anzeigen"
"show_discovered_interfaces": "Entdeckte Interfaces anzeigen",
"refresh": "Refresh",
"engine": "Engine",
"engine_wasm": "WASM",
"engine_fallback": "JS-Fallback",
"engine_checking": "Pruefen",
"engine_wasm_hint": "Path graph build uses Go WebAssembly",
"engine_fallback_hint": "WASM unavailable. Using JavaScript fallback",
"engine_checking_hint": "Detecting WebAssembly support",
"fps": "FPS",
"auto_update": "Auto-Update",
"live_layout": "Live-Layout",
"nodes": "Knoten",
"links": "Links",
"interfaces": "Schnittstellen",
"online": "Online",
"offline": "Offline",
"search_nodes_placeholder": "Knoten suchen ({count})...",
"clear_search": "Suche leeren"
},
"banishment": {
"title": "Verbannt",

View file

@ -1143,8 +1143,11 @@
"process_threads": "Threads",
"process_uptime": "Uptime",
"memory_pressure": "Memory pressure",
"memory_pressure_relaxed": "Relaxed (low memory)",
"memory_pressure_relaxed": "Active (low host RAM)",
"memory_pressure_normal": "Normal",
"memory_pressure_paths": "{count} paths tracked",
"path_table": "Path table",
"path_table_count": "{count} paths",
"env_host_battery": "Host battery",
"app_battery_use": "Est. MeshChatX battery",
"app_battery_use_hint": "Estimated from MeshChatX CPU time since start. Not an OS battery attribution.",
@ -1156,7 +1159,24 @@
"app_battery_intensity_low": "low",
"app_battery_intensity_moderate": "moderate",
"app_battery_intensity_high": "high",
"app_battery_intensity_very_high": "very high"
"app_battery_intensity_very_high": "very high",
"battery_saver": "Battery saver",
"battery_saver_off": "Off",
"battery_saver_on": "On",
"battery_saver_measures": "Active measures",
"battery_saver_measure": {
"disableVisualiserDiscovery": "Visualiser discovery hidden",
"hideOfflineInterfaces": "Offline interfaces hidden",
"maxVisualiserInterfaces": "Visualiser interface limit",
"disableVisualiserAutoReload": "Visualiser auto-reload off",
"slowVisualiserReload": "Slower visualiser reload",
"disableVisualiserLiveLayout": "Live layout off",
"reduceBackgroundPolling": "Slower background polling",
"reduceInterfacesDiscovery": "Slower interface discovery polls",
"applyInterfaceBitrateLimits": "Forced interface bitrates"
},
"top_memory_consumer": "Top memory",
"top_cpu_consumer": "Top CPU"
},
"interfaces": {
"title": "Interfaces",
@ -1748,7 +1768,7 @@
"settings": {
"tabs": {
"general": "General",
"general_desc": "Language, appearance, platform, maps, and shortcuts",
"general_desc": "Language, appearance, battery saver, platform, maps, and shortcuts",
"messages": "Messages",
"messages_desc": "LXMF, stickers, and safety",
"network": "Network",
@ -1780,6 +1800,45 @@
"share_apk_short_hint": "Opens the system share sheet for this APK.",
"share_apk": "Share app (APK)",
"share_apk_failed": "Could not open the share sheet for the APK.",
"battery": {
"eyebrow": "Power",
"title": "Battery saver",
"description": "Reduce UI polling and visualiser work on battery. Messaging and mesh delivery stay active. Each option below applies only while battery saver is on.",
"enabled": "Enable battery saver",
"enabled_desc": "Master switch for the power-saving options below.",
"options_heading": "When battery saver is on",
"disable_visualiser_discovery": "Hide discovered interfaces in visualiser",
"disable_visualiser_discovery_desc": "Stops drawing discovered-interface nodes on the network graph.",
"hide_offline_interfaces": "Hide offline interfaces in visualiser",
"hide_offline_interfaces_desc": "Only show online (and path-table) interfaces on the graph.",
"max_visualiser_interfaces": "Max interfaces on visualiser",
"max_visualiser_interfaces_desc": "0 means unlimited. Prefer online interfaces when truncating.",
"visualiser_reload_seconds": "Visualiser auto-reload interval (seconds)",
"visualiser_reload_seconds_desc": "0 disables auto-reload while saver is on. Default without saver is 15s.",
"disable_visualiser_live_layout": "Disable visualiser live layout",
"disable_visualiser_live_layout_desc": "Turns off continuous layout/physics work on the graph.",
"reduce_background_polling": "Slow background status polling",
"reduce_background_polling_desc": "Multiplies App shell poll intervals (calls, unread, app info).",
"background_poll_multiplier": "Background poll multiplier",
"background_poll_multiplier_desc": "210. Applied to App shell intervals when the option above is on.",
"reduce_interfaces_discovery": "Slow Interfaces page polls",
"reduce_interfaces_discovery_desc": "Uses the intervals below for stats and discovered-interface refresh.",
"interfaces_stats_poll_seconds": "Interfaces stats poll (seconds)",
"interfaces_discovery_poll_seconds": "Discovered interfaces poll (seconds)",
"apply_interface_bitrate_limits": "Force interface bitrates",
"apply_interface_bitrate_limits_desc": "Write forced bitrate (bps) into selected Reticulum interfaces and reload RNS when applied.",
"interface_bitrate_limits_help": "Enter a bitrate in bits per second for each interface you want to cap. Leave blank to skip. Apply writes the config and reloads Reticulum.",
"interface_bitrate_limits_empty": "No interfaces found yet.",
"interface_bitrate_placeholder": "bps",
"apply_bitrates_reload": "Apply bitrates and reload RNS",
"restore_bitrates_reload": "Restore previous bitrates and reload",
"bitrates_applied": "Updated {count} interface bitrate(s) and reloaded RNS",
"bitrates_restored": "Restored {count} interface bitrate(s) and reloaded RNS",
"bitrates_none_applied": "No bitrate limits matched current interfaces",
"bitrates_none_restored": "No previous bitrates to restore",
"bitrates_apply_failed": "Failed to apply interface bitrates",
"bitrates_restore_failed": "Failed to restore interface bitrates"
},
"nomad_micron_wasm_title": "Micron (WASM)",
"nomad_micron_wasm_desc_before_link": "Use a faster engine (",
"nomad_micron_wasm_link_label": "micron-parser-go",
@ -1884,7 +1943,25 @@
"failed_load": "Failed to load network data",
"max_hops_filter": "Max hops",
"show_disabled_interfaces": "Show offline interfaces",
"show_discovered_interfaces": "Show discovered interfaces"
"show_discovered_interfaces": "Show discovered interfaces",
"refresh": "Refresh",
"engine": "Engine",
"engine_wasm": "WASM",
"engine_fallback": "JS fallback",
"engine_checking": "Checking",
"engine_wasm_hint": "Graph build and force layout run in Go WebAssembly. Canvas draw stays in the browser",
"engine_fallback_hint": "WASM unavailable. Graph build uses JavaScript. Live layout uses vis-network physics",
"engine_checking_hint": "Detecting WebAssembly support",
"fps": "FPS",
"auto_update": "Auto Update",
"live_layout": "Live Layout",
"nodes": "Nodes",
"links": "Links",
"interfaces": "Interfaces",
"online": "Online",
"offline": "Offline",
"search_nodes_placeholder": "Search nodes ({count})...",
"clear_search": "Clear search"
},
"banishment": {
"title": "Banished",

View file

@ -1143,7 +1143,7 @@
"process_threads": "Hilos",
"process_uptime": "Tiempo activo",
"memory_pressure": "Presion de memoria",
"memory_pressure_relaxed": "Relajado (poca memoria)",
"memory_pressure_relaxed": "Active (low host RAM)",
"memory_pressure_paths": "{count} rutas rastreadas",
"env_host_battery": "Bateria del host",
"app_battery_use": "Bat. est. MeshChatX",
@ -1156,7 +1156,27 @@
"app_battery_intensity_low": "baja",
"app_battery_intensity_moderate": "moderada",
"app_battery_intensity_high": "alta",
"app_battery_intensity_very_high": "muy alta"
"app_battery_intensity_very_high": "muy alta",
"battery_saver": "Ahorro de bateria",
"battery_saver_off": "Desactivado",
"battery_saver_on": "Activado",
"battery_saver_measures": "Medidas activas",
"battery_saver_measure": {
"disableVisualiserDiscovery": "Visualiser discovery hidden",
"hideOfflineInterfaces": "Offline interfaces hidden",
"maxVisualiserInterfaces": "Visualiser interface limit",
"disableVisualiserAutoReload": "Visualiser auto-reload off",
"slowVisualiserReload": "Slower visualiser reload",
"disableVisualiserLiveLayout": "Live layout off",
"reduceBackgroundPolling": "Slower background polling",
"reduceInterfacesDiscovery": "Slower interface discovery polls",
"applyInterfaceBitrateLimits": "Forced interface bitrates"
},
"top_memory_consumer": "Top memory",
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
"path_table_count": "{count} paths"
},
"interfaces": {
"title": "Interfaces",
@ -1810,7 +1830,46 @@
},
"failed_update_reticulum_instance": "¡Error al actualizar la configuración de la instancia de Reticulum!",
"keyboard_shortcuts_title": "Atajos de teclado",
"keyboard_shortcuts_description": "Personaliza acciones rápidas de teclado. Contraído por defecto en móviles."
"keyboard_shortcuts_description": "Personaliza acciones rápidas de teclado. Contraído por defecto en móviles.",
"battery": {
"eyebrow": "Power",
"title": "Battery saver",
"description": "Reduce UI polling and visualiser work on battery. Messaging and mesh delivery stay active. Each option below applies only while battery saver is on.",
"enabled": "Enable battery saver",
"enabled_desc": "Master switch for the power-saving options below.",
"options_heading": "When battery saver is on",
"disable_visualiser_discovery": "Hide discovered interfaces in visualiser",
"disable_visualiser_discovery_desc": "Stops drawing discovered-interface nodes on the network graph.",
"hide_offline_interfaces": "Hide offline interfaces in visualiser",
"hide_offline_interfaces_desc": "Only show online (and path-table) interfaces on the graph.",
"max_visualiser_interfaces": "Max interfaces on visualiser",
"max_visualiser_interfaces_desc": "0 means unlimited. Prefer online interfaces when truncating.",
"visualiser_reload_seconds": "Visualiser auto-reload interval (seconds)",
"visualiser_reload_seconds_desc": "0 disables auto-reload while saver is on. Default without saver is 15s.",
"disable_visualiser_live_layout": "Disable visualiser live layout",
"disable_visualiser_live_layout_desc": "Turns off continuous layout/physics work on the graph.",
"reduce_background_polling": "Slow background status polling",
"reduce_background_polling_desc": "Multiplies App shell poll intervals (calls, unread, app info).",
"background_poll_multiplier": "Background poll multiplier",
"background_poll_multiplier_desc": "210. Applied to App shell intervals when the option above is on.",
"reduce_interfaces_discovery": "Slow Interfaces page polls",
"reduce_interfaces_discovery_desc": "Uses the intervals below for stats and discovered-interface refresh.",
"interfaces_stats_poll_seconds": "Interfaces stats poll (seconds)",
"interfaces_discovery_poll_seconds": "Discovered interfaces poll (seconds)",
"apply_interface_bitrate_limits": "Force interface bitrates",
"apply_interface_bitrate_limits_desc": "Write forced bitrate (bps) into selected Reticulum interfaces and reload RNS when applied.",
"interface_bitrate_limits_help": "Enter a bitrate in bits per second for each interface you want to cap. Leave blank to skip. Apply writes the config and reloads Reticulum.",
"interface_bitrate_limits_empty": "No interfaces found yet.",
"interface_bitrate_placeholder": "bps",
"apply_bitrates_reload": "Apply bitrates and reload RNS",
"restore_bitrates_reload": "Restore previous bitrates and reload",
"bitrates_applied": "Updated {count} interface bitrate(s) and reloaded RNS",
"bitrates_restored": "Restored {count} interface bitrate(s) and reloaded RNS",
"bitrates_none_applied": "No bitrate limits matched current interfaces",
"bitrates_none_restored": "No previous bitrates to restore",
"bitrates_apply_failed": "Failed to apply interface bitrates",
"bitrates_restore_failed": "Failed to restore interface bitrates"
}
},
"debug": {
"title": "Registros de depuración",
@ -1884,7 +1943,25 @@
"failed_load": "Error al cargar datos de red",
"max_hops_filter": "Max Hops",
"show_disabled_interfaces": "Mostrar interfaces offline",
"show_discovered_interfaces": "Mostrar interfaces descubiertas"
"show_discovered_interfaces": "Mostrar interfaces descubiertas",
"refresh": "Refresh",
"engine": "Engine",
"engine_wasm": "WASM",
"engine_fallback": "Reserva JS",
"engine_checking": "Comprobando",
"engine_wasm_hint": "Path graph build uses Go WebAssembly",
"engine_fallback_hint": "WASM unavailable. Using JavaScript fallback",
"engine_checking_hint": "Detecting WebAssembly support",
"fps": "FPS",
"auto_update": "Actualizacion auto",
"live_layout": "Diseno en vivo",
"nodes": "Nodos",
"links": "Enlaces",
"interfaces": "Interfaces",
"online": "En linea",
"offline": "Fuera",
"search_nodes_placeholder": "Buscar nodos ({count})...",
"clear_search": "Borrar busqueda"
},
"banishment": {
"title": "Desterrados",

View file

@ -1143,7 +1143,7 @@
"process_threads": "Sailkeet",
"process_uptime": "Kayttoaaika",
"memory_pressure": "Muistipaine",
"memory_pressure_relaxed": "Lievitetty (vahan muistia)",
"memory_pressure_relaxed": "Active (low host RAM)",
"memory_pressure_paths": "{count} polkua seurannassa",
"env_host_battery": "Isannan akku",
"app_battery_use": "Arvio MeshChatX-akku",
@ -1156,7 +1156,27 @@
"app_battery_intensity_low": "matala",
"app_battery_intensity_moderate": "kohtalainen",
"app_battery_intensity_high": "korkea",
"app_battery_intensity_very_high": "hyvin korkea"
"app_battery_intensity_very_high": "hyvin korkea",
"battery_saver": "Akunsäästötila",
"battery_saver_off": "Pois",
"battery_saver_on": "Paalla",
"battery_saver_measures": "Aktiiviset toimet",
"battery_saver_measure": {
"disableVisualiserDiscovery": "Visualiser discovery hidden",
"hideOfflineInterfaces": "Offline interfaces hidden",
"maxVisualiserInterfaces": "Visualiser interface limit",
"disableVisualiserAutoReload": "Visualiser auto-reload off",
"slowVisualiserReload": "Slower visualiser reload",
"disableVisualiserLiveLayout": "Live layout off",
"reduceBackgroundPolling": "Slower background polling",
"reduceInterfacesDiscovery": "Slower interface discovery polls",
"applyInterfaceBitrateLimits": "Forced interface bitrates"
},
"top_memory_consumer": "Top memory",
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
"path_table_count": "{count} paths"
},
"interfaces": {
"title": "Sovittimet",
@ -1810,7 +1830,46 @@
"micron_wasm_update_toast_reverted": "Palautettiin paketin Micron WASM.",
"failed_update_reticulum_instance": "Reticulum-esiintymän asetusten päivitys epäonnistui!",
"keyboard_shortcuts_title": "Pikanäppäimet",
"keyboard_shortcuts_description": "Mukauta pikanäppäimiä. Puhelimissa oletuksena tiivistetty."
"keyboard_shortcuts_description": "Mukauta pikanäppäimiä. Puhelimissa oletuksena tiivistetty.",
"battery": {
"eyebrow": "Power",
"title": "Battery saver",
"description": "Reduce UI polling and visualiser work on battery. Messaging and mesh delivery stay active. Each option below applies only while battery saver is on.",
"enabled": "Enable battery saver",
"enabled_desc": "Master switch for the power-saving options below.",
"options_heading": "When battery saver is on",
"disable_visualiser_discovery": "Hide discovered interfaces in visualiser",
"disable_visualiser_discovery_desc": "Stops drawing discovered-interface nodes on the network graph.",
"hide_offline_interfaces": "Hide offline interfaces in visualiser",
"hide_offline_interfaces_desc": "Only show online (and path-table) interfaces on the graph.",
"max_visualiser_interfaces": "Max interfaces on visualiser",
"max_visualiser_interfaces_desc": "0 means unlimited. Prefer online interfaces when truncating.",
"visualiser_reload_seconds": "Visualiser auto-reload interval (seconds)",
"visualiser_reload_seconds_desc": "0 disables auto-reload while saver is on. Default without saver is 15s.",
"disable_visualiser_live_layout": "Disable visualiser live layout",
"disable_visualiser_live_layout_desc": "Turns off continuous layout/physics work on the graph.",
"reduce_background_polling": "Slow background status polling",
"reduce_background_polling_desc": "Multiplies App shell poll intervals (calls, unread, app info).",
"background_poll_multiplier": "Background poll multiplier",
"background_poll_multiplier_desc": "210. Applied to App shell intervals when the option above is on.",
"reduce_interfaces_discovery": "Slow Interfaces page polls",
"reduce_interfaces_discovery_desc": "Uses the intervals below for stats and discovered-interface refresh.",
"interfaces_stats_poll_seconds": "Interfaces stats poll (seconds)",
"interfaces_discovery_poll_seconds": "Discovered interfaces poll (seconds)",
"apply_interface_bitrate_limits": "Force interface bitrates",
"apply_interface_bitrate_limits_desc": "Write forced bitrate (bps) into selected Reticulum interfaces and reload RNS when applied.",
"interface_bitrate_limits_help": "Enter a bitrate in bits per second for each interface you want to cap. Leave blank to skip. Apply writes the config and reloads Reticulum.",
"interface_bitrate_limits_empty": "No interfaces found yet.",
"interface_bitrate_placeholder": "bps",
"apply_bitrates_reload": "Apply bitrates and reload RNS",
"restore_bitrates_reload": "Restore previous bitrates and reload",
"bitrates_applied": "Updated {count} interface bitrate(s) and reloaded RNS",
"bitrates_restored": "Restored {count} interface bitrate(s) and reloaded RNS",
"bitrates_none_applied": "No bitrate limits matched current interfaces",
"bitrates_none_restored": "No previous bitrates to restore",
"bitrates_apply_failed": "Failed to apply interface bitrates",
"bitrates_restore_failed": "Failed to restore interface bitrates"
}
},
"debug": {
"title": "Vianetsintälokit",
@ -1884,7 +1943,25 @@
"failed_load": "Verkkodatan lataus epäonnistui",
"max_hops_filter": "Hyppyjen enimmäismäärä",
"show_disabled_interfaces": "Näytä käyttämättömät sovittimet",
"show_discovered_interfaces": "Näytä havaitut sovittimet"
"show_discovered_interfaces": "Näytä havaitut sovittimet",
"refresh": "Refresh",
"engine": "Engine",
"engine_wasm": "WASM",
"engine_fallback": "JS-varajärjestelmä",
"engine_checking": "Tarkistetaan",
"engine_wasm_hint": "Path graph build uses Go WebAssembly",
"engine_fallback_hint": "WASM unavailable. Using JavaScript fallback",
"engine_checking_hint": "Detecting WebAssembly support",
"fps": "FPS",
"auto_update": "Automaattipäivitys",
"live_layout": "Elävä asettelu",
"nodes": "Solmut",
"links": "Linkit",
"interfaces": "Liitännät",
"online": "Online",
"offline": "Offline",
"search_nodes_placeholder": "Hae solmuja ({count})...",
"clear_search": "Tyhjennä haku"
},
"banishment": {
"title": "Karkotus",

View file

@ -1143,7 +1143,7 @@
"process_threads": "Threads",
"process_uptime": "Duree d'activite",
"memory_pressure": "Pression memoire",
"memory_pressure_relaxed": "Assoupli (memoire basse)",
"memory_pressure_relaxed": "Active (low host RAM)",
"memory_pressure_paths": "{count} chemins suivis",
"env_host_battery": "Batterie hote",
"app_battery_use": "Batt. est. MeshChatX",
@ -1156,7 +1156,27 @@
"app_battery_intensity_low": "faible",
"app_battery_intensity_moderate": "moderee",
"app_battery_intensity_high": "elevee",
"app_battery_intensity_very_high": "tres elevee"
"app_battery_intensity_very_high": "tres elevee",
"battery_saver": "Economie de batterie",
"battery_saver_off": "Desactive",
"battery_saver_on": "Active",
"battery_saver_measures": "Mesures actives",
"battery_saver_measure": {
"disableVisualiserDiscovery": "Visualiser discovery hidden",
"hideOfflineInterfaces": "Offline interfaces hidden",
"maxVisualiserInterfaces": "Visualiser interface limit",
"disableVisualiserAutoReload": "Visualiser auto-reload off",
"slowVisualiserReload": "Slower visualiser reload",
"disableVisualiserLiveLayout": "Live layout off",
"reduceBackgroundPolling": "Slower background polling",
"reduceInterfacesDiscovery": "Slower interface discovery polls",
"applyInterfaceBitrateLimits": "Forced interface bitrates"
},
"top_memory_consumer": "Top memory",
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
"path_table_count": "{count} paths"
},
"interfaces": {
"title": "Interfaces",
@ -1810,7 +1830,46 @@
},
"failed_update_reticulum_instance": "Échec de la mise à jour des paramètres de l'instance Reticulum",
"keyboard_shortcuts_title": "Raccourcis clavier",
"keyboard_shortcuts_description": "Personnalisez les actions clavier. Replié par défaut sur mobile."
"keyboard_shortcuts_description": "Personnalisez les actions clavier. Replié par défaut sur mobile.",
"battery": {
"eyebrow": "Power",
"title": "Battery saver",
"description": "Reduce UI polling and visualiser work on battery. Messaging and mesh delivery stay active. Each option below applies only while battery saver is on.",
"enabled": "Enable battery saver",
"enabled_desc": "Master switch for the power-saving options below.",
"options_heading": "When battery saver is on",
"disable_visualiser_discovery": "Hide discovered interfaces in visualiser",
"disable_visualiser_discovery_desc": "Stops drawing discovered-interface nodes on the network graph.",
"hide_offline_interfaces": "Hide offline interfaces in visualiser",
"hide_offline_interfaces_desc": "Only show online (and path-table) interfaces on the graph.",
"max_visualiser_interfaces": "Max interfaces on visualiser",
"max_visualiser_interfaces_desc": "0 means unlimited. Prefer online interfaces when truncating.",
"visualiser_reload_seconds": "Visualiser auto-reload interval (seconds)",
"visualiser_reload_seconds_desc": "0 disables auto-reload while saver is on. Default without saver is 15s.",
"disable_visualiser_live_layout": "Disable visualiser live layout",
"disable_visualiser_live_layout_desc": "Turns off continuous layout/physics work on the graph.",
"reduce_background_polling": "Slow background status polling",
"reduce_background_polling_desc": "Multiplies App shell poll intervals (calls, unread, app info).",
"background_poll_multiplier": "Background poll multiplier",
"background_poll_multiplier_desc": "210. Applied to App shell intervals when the option above is on.",
"reduce_interfaces_discovery": "Slow Interfaces page polls",
"reduce_interfaces_discovery_desc": "Uses the intervals below for stats and discovered-interface refresh.",
"interfaces_stats_poll_seconds": "Interfaces stats poll (seconds)",
"interfaces_discovery_poll_seconds": "Discovered interfaces poll (seconds)",
"apply_interface_bitrate_limits": "Force interface bitrates",
"apply_interface_bitrate_limits_desc": "Write forced bitrate (bps) into selected Reticulum interfaces and reload RNS when applied.",
"interface_bitrate_limits_help": "Enter a bitrate in bits per second for each interface you want to cap. Leave blank to skip. Apply writes the config and reloads Reticulum.",
"interface_bitrate_limits_empty": "No interfaces found yet.",
"interface_bitrate_placeholder": "bps",
"apply_bitrates_reload": "Apply bitrates and reload RNS",
"restore_bitrates_reload": "Restore previous bitrates and reload",
"bitrates_applied": "Updated {count} interface bitrate(s) and reloaded RNS",
"bitrates_restored": "Restored {count} interface bitrate(s) and reloaded RNS",
"bitrates_none_applied": "No bitrate limits matched current interfaces",
"bitrates_none_restored": "No previous bitrates to restore",
"bitrates_apply_failed": "Failed to apply interface bitrates",
"bitrates_restore_failed": "Failed to restore interface bitrates"
}
},
"debug": {
"title": "Débogues",
@ -1884,7 +1943,25 @@
"failed_load": "Impossible de charger les données du réseau",
"max_hops_filter": "Max houblon",
"show_disabled_interfaces": "Afficher les interfaces hors ligne",
"show_discovered_interfaces": "Afficher les interfaces découvertes"
"show_discovered_interfaces": "Afficher les interfaces découvertes",
"refresh": "Refresh",
"engine": "Engine",
"engine_wasm": "WASM",
"engine_fallback": "Repli JS",
"engine_checking": "Verification",
"engine_wasm_hint": "Path graph build uses Go WebAssembly",
"engine_fallback_hint": "WASM unavailable. Using JavaScript fallback",
"engine_checking_hint": "Detecting WebAssembly support",
"fps": "FPS",
"auto_update": "MAJ auto",
"live_layout": "Disposition live",
"nodes": "Noeuds",
"links": "Liens",
"interfaces": "Interfaces",
"online": "En ligne",
"offline": "Hors ligne",
"search_nodes_placeholder": "Rechercher des noeuds ({count})...",
"clear_search": "Effacer la recherche"
},
"banishment": {
"title": "Interdit",

View file

@ -1195,7 +1195,7 @@
"process_threads": "Thread",
"process_uptime": "Tempo di attivita",
"memory_pressure": "Pressione memoria",
"memory_pressure_relaxed": "Rilassato (poca memoria)",
"memory_pressure_relaxed": "Active (low host RAM)",
"memory_pressure_paths": "{count} percorsi tracciati",
"env_host_battery": "Batteria host",
"app_battery_use": "Batt. stim. MeshChatX",
@ -1208,7 +1208,27 @@
"app_battery_intensity_low": "bassa",
"app_battery_intensity_moderate": "moderata",
"app_battery_intensity_high": "alta",
"app_battery_intensity_very_high": "molto alta"
"app_battery_intensity_very_high": "molto alta",
"battery_saver": "Risparmio batteria",
"battery_saver_off": "Disattivo",
"battery_saver_on": "Attivo",
"battery_saver_measures": "Misure attive",
"battery_saver_measure": {
"disableVisualiserDiscovery": "Visualiser discovery hidden",
"hideOfflineInterfaces": "Offline interfaces hidden",
"maxVisualiserInterfaces": "Visualiser interface limit",
"disableVisualiserAutoReload": "Visualiser auto-reload off",
"slowVisualiserReload": "Slower visualiser reload",
"disableVisualiserLiveLayout": "Live layout off",
"reduceBackgroundPolling": "Slower background polling",
"reduceInterfacesDiscovery": "Slower interface discovery polls",
"applyInterfaceBitrateLimits": "Forced interface bitrates"
},
"top_memory_consumer": "Top memory",
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
"path_table_count": "{count} paths"
},
"interfaces": {
"title": "Interfacce",
@ -1862,7 +1882,46 @@
},
"failed_update_reticulum_instance": "Impossibile aggiornare le impostazioni dell'istanza Reticulum!",
"keyboard_shortcuts_title": "Scorciatoie da tastiera",
"keyboard_shortcuts_description": "Personalizza le azioni rapide da tastiera. Compresso di default su telefono."
"keyboard_shortcuts_description": "Personalizza le azioni rapide da tastiera. Compresso di default su telefono.",
"battery": {
"eyebrow": "Power",
"title": "Battery saver",
"description": "Reduce UI polling and visualiser work on battery. Messaging and mesh delivery stay active. Each option below applies only while battery saver is on.",
"enabled": "Enable battery saver",
"enabled_desc": "Master switch for the power-saving options below.",
"options_heading": "When battery saver is on",
"disable_visualiser_discovery": "Hide discovered interfaces in visualiser",
"disable_visualiser_discovery_desc": "Stops drawing discovered-interface nodes on the network graph.",
"hide_offline_interfaces": "Hide offline interfaces in visualiser",
"hide_offline_interfaces_desc": "Only show online (and path-table) interfaces on the graph.",
"max_visualiser_interfaces": "Max interfaces on visualiser",
"max_visualiser_interfaces_desc": "0 means unlimited. Prefer online interfaces when truncating.",
"visualiser_reload_seconds": "Visualiser auto-reload interval (seconds)",
"visualiser_reload_seconds_desc": "0 disables auto-reload while saver is on. Default without saver is 15s.",
"disable_visualiser_live_layout": "Disable visualiser live layout",
"disable_visualiser_live_layout_desc": "Turns off continuous layout/physics work on the graph.",
"reduce_background_polling": "Slow background status polling",
"reduce_background_polling_desc": "Multiplies App shell poll intervals (calls, unread, app info).",
"background_poll_multiplier": "Background poll multiplier",
"background_poll_multiplier_desc": "210. Applied to App shell intervals when the option above is on.",
"reduce_interfaces_discovery": "Slow Interfaces page polls",
"reduce_interfaces_discovery_desc": "Uses the intervals below for stats and discovered-interface refresh.",
"interfaces_stats_poll_seconds": "Interfaces stats poll (seconds)",
"interfaces_discovery_poll_seconds": "Discovered interfaces poll (seconds)",
"apply_interface_bitrate_limits": "Force interface bitrates",
"apply_interface_bitrate_limits_desc": "Write forced bitrate (bps) into selected Reticulum interfaces and reload RNS when applied.",
"interface_bitrate_limits_help": "Enter a bitrate in bits per second for each interface you want to cap. Leave blank to skip. Apply writes the config and reloads Reticulum.",
"interface_bitrate_limits_empty": "No interfaces found yet.",
"interface_bitrate_placeholder": "bps",
"apply_bitrates_reload": "Apply bitrates and reload RNS",
"restore_bitrates_reload": "Restore previous bitrates and reload",
"bitrates_applied": "Updated {count} interface bitrate(s) and reloaded RNS",
"bitrates_restored": "Restored {count} interface bitrate(s) and reloaded RNS",
"bitrates_none_applied": "No bitrate limits matched current interfaces",
"bitrates_none_restored": "No previous bitrates to restore",
"bitrates_apply_failed": "Failed to apply interface bitrates",
"bitrates_restore_failed": "Failed to restore interface bitrates"
}
},
"debug": {
"title": "Log di Debug",
@ -1936,7 +1995,25 @@
"failed_load": "Impossibile caricare i dati di rete",
"max_hops_filter": "Salti max",
"show_disabled_interfaces": "Mostra interfacce offline",
"show_discovered_interfaces": "Mostra interfacce scoperte"
"show_discovered_interfaces": "Mostra interfacce scoperte",
"refresh": "Refresh",
"engine": "Engine",
"engine_wasm": "WASM",
"engine_fallback": "Fallback JS",
"engine_checking": "Verifica",
"engine_wasm_hint": "Path graph build uses Go WebAssembly",
"engine_fallback_hint": "WASM unavailable. Using JavaScript fallback",
"engine_checking_hint": "Detecting WebAssembly support",
"fps": "FPS",
"auto_update": "Aggiornamento auto",
"live_layout": "Layout live",
"nodes": "Nodi",
"links": "Collegamenti",
"interfaces": "Interfacce",
"online": "Online",
"offline": "Offline",
"search_nodes_placeholder": "Cerca nodi ({count})...",
"clear_search": "Cancella ricerca"
},
"banishment": {
"title": "Esiliati",

View file

@ -1143,7 +1143,7 @@
"process_threads": "Threads",
"process_uptime": "Uptime",
"memory_pressure": "Geheugendruk",
"memory_pressure_relaxed": "Versoepeld (weinig geheugen)",
"memory_pressure_relaxed": "Active (low host RAM)",
"memory_pressure_paths": "{count} paden gevolgd",
"env_host_battery": "Hostbatterij",
"app_battery_use": "Gesch. MeshChatX-batterij",
@ -1156,7 +1156,27 @@
"app_battery_intensity_low": "laag",
"app_battery_intensity_moderate": "matig",
"app_battery_intensity_high": "hoog",
"app_battery_intensity_very_high": "zeer hoog"
"app_battery_intensity_very_high": "zeer hoog",
"battery_saver": "Batterijbesparing",
"battery_saver_off": "Uit",
"battery_saver_on": "Aan",
"battery_saver_measures": "Actieve maatregelen",
"battery_saver_measure": {
"disableVisualiserDiscovery": "Visualiser discovery hidden",
"hideOfflineInterfaces": "Offline interfaces hidden",
"maxVisualiserInterfaces": "Visualiser interface limit",
"disableVisualiserAutoReload": "Visualiser auto-reload off",
"slowVisualiserReload": "Slower visualiser reload",
"disableVisualiserLiveLayout": "Live layout off",
"reduceBackgroundPolling": "Slower background polling",
"reduceInterfacesDiscovery": "Slower interface discovery polls",
"applyInterfaceBitrateLimits": "Forced interface bitrates"
},
"top_memory_consumer": "Top memory",
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
"path_table_count": "{count} paths"
},
"interfaces": {
"title": "Interfaces",
@ -1810,7 +1830,46 @@
},
"failed_update_reticulum_instance": "Instellingen van Reticulum-instantie bijwerken mislukt!",
"keyboard_shortcuts_title": "Sneltoetsen",
"keyboard_shortcuts_description": "Pas snelle toetsenbordacties aan. Standaard ingeklapt op telefoons."
"keyboard_shortcuts_description": "Pas snelle toetsenbordacties aan. Standaard ingeklapt op telefoons.",
"battery": {
"eyebrow": "Power",
"title": "Battery saver",
"description": "Reduce UI polling and visualiser work on battery. Messaging and mesh delivery stay active. Each option below applies only while battery saver is on.",
"enabled": "Enable battery saver",
"enabled_desc": "Master switch for the power-saving options below.",
"options_heading": "When battery saver is on",
"disable_visualiser_discovery": "Hide discovered interfaces in visualiser",
"disable_visualiser_discovery_desc": "Stops drawing discovered-interface nodes on the network graph.",
"hide_offline_interfaces": "Hide offline interfaces in visualiser",
"hide_offline_interfaces_desc": "Only show online (and path-table) interfaces on the graph.",
"max_visualiser_interfaces": "Max interfaces on visualiser",
"max_visualiser_interfaces_desc": "0 means unlimited. Prefer online interfaces when truncating.",
"visualiser_reload_seconds": "Visualiser auto-reload interval (seconds)",
"visualiser_reload_seconds_desc": "0 disables auto-reload while saver is on. Default without saver is 15s.",
"disable_visualiser_live_layout": "Disable visualiser live layout",
"disable_visualiser_live_layout_desc": "Turns off continuous layout/physics work on the graph.",
"reduce_background_polling": "Slow background status polling",
"reduce_background_polling_desc": "Multiplies App shell poll intervals (calls, unread, app info).",
"background_poll_multiplier": "Background poll multiplier",
"background_poll_multiplier_desc": "210. Applied to App shell intervals when the option above is on.",
"reduce_interfaces_discovery": "Slow Interfaces page polls",
"reduce_interfaces_discovery_desc": "Uses the intervals below for stats and discovered-interface refresh.",
"interfaces_stats_poll_seconds": "Interfaces stats poll (seconds)",
"interfaces_discovery_poll_seconds": "Discovered interfaces poll (seconds)",
"apply_interface_bitrate_limits": "Force interface bitrates",
"apply_interface_bitrate_limits_desc": "Write forced bitrate (bps) into selected Reticulum interfaces and reload RNS when applied.",
"interface_bitrate_limits_help": "Enter a bitrate in bits per second for each interface you want to cap. Leave blank to skip. Apply writes the config and reloads Reticulum.",
"interface_bitrate_limits_empty": "No interfaces found yet.",
"interface_bitrate_placeholder": "bps",
"apply_bitrates_reload": "Apply bitrates and reload RNS",
"restore_bitrates_reload": "Restore previous bitrates and reload",
"bitrates_applied": "Updated {count} interface bitrate(s) and reloaded RNS",
"bitrates_restored": "Restored {count} interface bitrate(s) and reloaded RNS",
"bitrates_none_applied": "No bitrate limits matched current interfaces",
"bitrates_none_restored": "No previous bitrates to restore",
"bitrates_apply_failed": "Failed to apply interface bitrates",
"bitrates_restore_failed": "Failed to restore interface bitrates"
}
},
"debug": {
"title": "Debuglogs",
@ -1884,7 +1943,25 @@
"failed_load": "Kon netwerkgegevens niet laden",
"max_hops_filter": "Max. hop",
"show_disabled_interfaces": "Offline interfaces tonen",
"show_discovered_interfaces": "Ontdekte interfaces tonen"
"show_discovered_interfaces": "Ontdekte interfaces tonen",
"refresh": "Refresh",
"engine": "Engine",
"engine_wasm": "WASM",
"engine_fallback": "JS-fallback",
"engine_checking": "Controleren",
"engine_wasm_hint": "Path graph build uses Go WebAssembly",
"engine_fallback_hint": "WASM unavailable. Using JavaScript fallback",
"engine_checking_hint": "Detecting WebAssembly support",
"fps": "FPS",
"auto_update": "Auto-update",
"live_layout": "Live-layout",
"nodes": "Knooppunten",
"links": "Links",
"interfaces": "Interfaces",
"online": "Online",
"offline": "Offline",
"search_nodes_placeholder": "Zoek knooppunten ({count})...",
"clear_search": "Zoekopdracht wissen"
},
"banishment": {
"title": "Verbannen",

View file

@ -1195,7 +1195,7 @@
"process_threads": "Потоки",
"process_uptime": "Время работы",
"memory_pressure": "Давление памяти",
"memory_pressure_relaxed": "Ослаблен (мало памяти)",
"memory_pressure_relaxed": "Active (low host RAM)",
"memory_pressure_paths": "{count} путей отслеживается",
"env_host_battery": "Батарея хоста",
"app_battery_use": "Оц. батарея MeshChatX",
@ -1208,7 +1208,27 @@
"app_battery_intensity_low": "низкая",
"app_battery_intensity_moderate": "средняя",
"app_battery_intensity_high": "высокая",
"app_battery_intensity_very_high": "очень высокая"
"app_battery_intensity_very_high": "очень высокая",
"battery_saver": "Энергосбережение",
"battery_saver_off": "Выкл.",
"battery_saver_on": "Вкл.",
"battery_saver_measures": "Активные меры",
"battery_saver_measure": {
"disableVisualiserDiscovery": "Visualiser discovery hidden",
"hideOfflineInterfaces": "Offline interfaces hidden",
"maxVisualiserInterfaces": "Visualiser interface limit",
"disableVisualiserAutoReload": "Visualiser auto-reload off",
"slowVisualiserReload": "Slower visualiser reload",
"disableVisualiserLiveLayout": "Live layout off",
"reduceBackgroundPolling": "Slower background polling",
"reduceInterfacesDiscovery": "Slower interface discovery polls",
"applyInterfaceBitrateLimits": "Forced interface bitrates"
},
"top_memory_consumer": "Top memory",
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
"path_table_count": "{count} paths"
},
"interfaces": {
"title": "Интерфейсы",
@ -3214,7 +3234,46 @@
},
"failed_update_reticulum_instance": "Не удалось обновить настройки экземпляра Reticulum!",
"keyboard_shortcuts_title": "Горячие клавиши",
"keyboard_shortcuts_description": "Настройте быстрые клавиши. На телефонах свёрнуто по умолчанию."
"keyboard_shortcuts_description": "Настройте быстрые клавиши. На телефонах свёрнуто по умолчанию.",
"battery": {
"eyebrow": "Power",
"title": "Battery saver",
"description": "Reduce UI polling and visualiser work on battery. Messaging and mesh delivery stay active. Each option below applies only while battery saver is on.",
"enabled": "Enable battery saver",
"enabled_desc": "Master switch for the power-saving options below.",
"options_heading": "When battery saver is on",
"disable_visualiser_discovery": "Hide discovered interfaces in visualiser",
"disable_visualiser_discovery_desc": "Stops drawing discovered-interface nodes on the network graph.",
"hide_offline_interfaces": "Hide offline interfaces in visualiser",
"hide_offline_interfaces_desc": "Only show online (and path-table) interfaces on the graph.",
"max_visualiser_interfaces": "Max interfaces on visualiser",
"max_visualiser_interfaces_desc": "0 means unlimited. Prefer online interfaces when truncating.",
"visualiser_reload_seconds": "Visualiser auto-reload interval (seconds)",
"visualiser_reload_seconds_desc": "0 disables auto-reload while saver is on. Default without saver is 15s.",
"disable_visualiser_live_layout": "Disable visualiser live layout",
"disable_visualiser_live_layout_desc": "Turns off continuous layout/physics work on the graph.",
"reduce_background_polling": "Slow background status polling",
"reduce_background_polling_desc": "Multiplies App shell poll intervals (calls, unread, app info).",
"background_poll_multiplier": "Background poll multiplier",
"background_poll_multiplier_desc": "210. Applied to App shell intervals when the option above is on.",
"reduce_interfaces_discovery": "Slow Interfaces page polls",
"reduce_interfaces_discovery_desc": "Uses the intervals below for stats and discovered-interface refresh.",
"interfaces_stats_poll_seconds": "Interfaces stats poll (seconds)",
"interfaces_discovery_poll_seconds": "Discovered interfaces poll (seconds)",
"apply_interface_bitrate_limits": "Force interface bitrates",
"apply_interface_bitrate_limits_desc": "Write forced bitrate (bps) into selected Reticulum interfaces and reload RNS when applied.",
"interface_bitrate_limits_help": "Enter a bitrate in bits per second for each interface you want to cap. Leave blank to skip. Apply writes the config and reloads Reticulum.",
"interface_bitrate_limits_empty": "No interfaces found yet.",
"interface_bitrate_placeholder": "bps",
"apply_bitrates_reload": "Apply bitrates and reload RNS",
"restore_bitrates_reload": "Restore previous bitrates and reload",
"bitrates_applied": "Updated {count} interface bitrate(s) and reloaded RNS",
"bitrates_restored": "Restored {count} interface bitrate(s) and reloaded RNS",
"bitrates_none_applied": "No bitrate limits matched current interfaces",
"bitrates_none_restored": "No previous bitrates to restore",
"bitrates_apply_failed": "Failed to apply interface bitrates",
"bitrates_restore_failed": "Failed to restore interface bitrates"
}
},
"debug": {
"title": "Журнал отладки",
@ -3288,7 +3347,25 @@
"failed_load": "Не удалось загрузить данные сети",
"max_hops_filter": "Макс. хопов",
"show_disabled_interfaces": "Показать отключенные интерфейсы",
"show_discovered_interfaces": "Показать обнаруженные интерфейсы"
"show_discovered_interfaces": "Показать обнаруженные интерфейсы",
"refresh": "Refresh",
"engine": "Engine",
"engine_wasm": "WASM",
"engine_fallback": "JS fallback",
"engine_checking": "Проверка",
"engine_wasm_hint": "Path graph build uses Go WebAssembly",
"engine_fallback_hint": "WASM unavailable. Using JavaScript fallback",
"engine_checking_hint": "Detecting WebAssembly support",
"fps": "FPS",
"auto_update": "Автообновление",
"live_layout": "Живая раскладка",
"nodes": "Узлы",
"links": "Связи",
"interfaces": "Интерфейсы",
"online": "Онлайн",
"offline": "Офлайн",
"search_nodes_placeholder": "Поиск узлов ({count})...",
"clear_search": "Очистить поиск"
},
"banishment": {
"title": "Забаненные",

View file

@ -1143,7 +1143,7 @@
"process_threads": "线程",
"process_uptime": "运行时间",
"memory_pressure": "内存压力",
"memory_pressure_relaxed": "已放宽(低内存)",
"memory_pressure_relaxed": "Active (low host RAM)",
"memory_pressure_paths": "已跟踪 {count} 条路径",
"env_host_battery": "主机电池",
"app_battery_use": "MeshChatX 预估耗电",
@ -1156,7 +1156,27 @@
"app_battery_intensity_low": "低",
"app_battery_intensity_moderate": "中",
"app_battery_intensity_high": "高",
"app_battery_intensity_very_high": "很高"
"app_battery_intensity_very_high": "很高",
"battery_saver": "省电模式",
"battery_saver_off": "关闭",
"battery_saver_on": "开启",
"battery_saver_measures": "生效措施",
"battery_saver_measure": {
"disableVisualiserDiscovery": "Visualiser discovery hidden",
"hideOfflineInterfaces": "Offline interfaces hidden",
"maxVisualiserInterfaces": "Visualiser interface limit",
"disableVisualiserAutoReload": "Visualiser auto-reload off",
"slowVisualiserReload": "Slower visualiser reload",
"disableVisualiserLiveLayout": "Live layout off",
"reduceBackgroundPolling": "Slower background polling",
"reduceInterfacesDiscovery": "Slower interface discovery polls",
"applyInterfaceBitrateLimits": "Forced interface bitrates"
},
"top_memory_consumer": "Top memory",
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
"path_table_count": "{count} paths"
},
"interfaces": {
"title": "接口",
@ -1810,7 +1830,46 @@
},
"failed_update_reticulum_instance": "更新 Reticulum 实例设置失败!",
"keyboard_shortcuts_title": "键盘快捷键",
"keyboard_shortcuts_description": "自定义快捷键操作。手机上默认折叠。"
"keyboard_shortcuts_description": "自定义快捷键操作。手机上默认折叠。",
"battery": {
"eyebrow": "Power",
"title": "Battery saver",
"description": "Reduce UI polling and visualiser work on battery. Messaging and mesh delivery stay active. Each option below applies only while battery saver is on.",
"enabled": "Enable battery saver",
"enabled_desc": "Master switch for the power-saving options below.",
"options_heading": "When battery saver is on",
"disable_visualiser_discovery": "Hide discovered interfaces in visualiser",
"disable_visualiser_discovery_desc": "Stops drawing discovered-interface nodes on the network graph.",
"hide_offline_interfaces": "Hide offline interfaces in visualiser",
"hide_offline_interfaces_desc": "Only show online (and path-table) interfaces on the graph.",
"max_visualiser_interfaces": "Max interfaces on visualiser",
"max_visualiser_interfaces_desc": "0 means unlimited. Prefer online interfaces when truncating.",
"visualiser_reload_seconds": "Visualiser auto-reload interval (seconds)",
"visualiser_reload_seconds_desc": "0 disables auto-reload while saver is on. Default without saver is 15s.",
"disable_visualiser_live_layout": "Disable visualiser live layout",
"disable_visualiser_live_layout_desc": "Turns off continuous layout/physics work on the graph.",
"reduce_background_polling": "Slow background status polling",
"reduce_background_polling_desc": "Multiplies App shell poll intervals (calls, unread, app info).",
"background_poll_multiplier": "Background poll multiplier",
"background_poll_multiplier_desc": "210. Applied to App shell intervals when the option above is on.",
"reduce_interfaces_discovery": "Slow Interfaces page polls",
"reduce_interfaces_discovery_desc": "Uses the intervals below for stats and discovered-interface refresh.",
"interfaces_stats_poll_seconds": "Interfaces stats poll (seconds)",
"interfaces_discovery_poll_seconds": "Discovered interfaces poll (seconds)",
"apply_interface_bitrate_limits": "Force interface bitrates",
"apply_interface_bitrate_limits_desc": "Write forced bitrate (bps) into selected Reticulum interfaces and reload RNS when applied.",
"interface_bitrate_limits_help": "Enter a bitrate in bits per second for each interface you want to cap. Leave blank to skip. Apply writes the config and reloads Reticulum.",
"interface_bitrate_limits_empty": "No interfaces found yet.",
"interface_bitrate_placeholder": "bps",
"apply_bitrates_reload": "Apply bitrates and reload RNS",
"restore_bitrates_reload": "Restore previous bitrates and reload",
"bitrates_applied": "Updated {count} interface bitrate(s) and reloaded RNS",
"bitrates_restored": "Restored {count} interface bitrate(s) and reloaded RNS",
"bitrates_none_applied": "No bitrate limits matched current interfaces",
"bitrates_none_restored": "No previous bitrates to restore",
"bitrates_apply_failed": "Failed to apply interface bitrates",
"bitrates_restore_failed": "Failed to restore interface bitrates"
}
},
"debug": {
"title": "调试日志",
@ -1884,7 +1943,25 @@
"failed_load": "加载网络数据失败",
"max_hops_filter": "最大跳数",
"show_disabled_interfaces": "显示离线接口",
"show_discovered_interfaces": "显示已发现接口"
"show_discovered_interfaces": "显示已发现接口",
"refresh": "刷新",
"engine": "引擎",
"engine_wasm": "WASM",
"engine_fallback": "JS 回退",
"engine_checking": "检测中",
"engine_wasm_hint": "Path graph build uses Go WebAssembly",
"engine_fallback_hint": "WASM unavailable. Using JavaScript fallback",
"engine_checking_hint": "Detecting WebAssembly support",
"fps": "帧率",
"auto_update": "自动更新",
"live_layout": "实时布局",
"nodes": "节点",
"links": "链路",
"interfaces": "接口",
"online": "在线",
"offline": "离线",
"search_nodes_placeholder": "搜索节点 ({count})...",
"clear_search": "清除搜索"
},
"banishment": {
"title": "放逐",

View file

@ -0,0 +1,6 @@
{
"version": "1.0.0",
"wasm": "sha384-2R/q2Rz5H8EC/+/n7Tu8nCVP+GoJRAWWHFTUpk9uMwUIsVC/nVb024C0zs4cU512",
"wasmExec": "sha384-PWCs+V4BDf9yY1yjkD/p+9xNEs4iEbuvq+HezAOJiY3XL5GI6VyJXMsvnjiwNbce",
"wasmExecSource": "/usr/lib/go/lib/wasm/wasm_exec.js"
}

View file

@ -11,7 +11,7 @@
"scripts": {
"dev": "vite dev",
"watch": "pnpm run build-frontend -- --watch",
"prebuild-frontend": "node scripts/fetch-micron-wasm.mjs && node scripts/sync-meshchatx-docs.js",
"prebuild-frontend": "node scripts/fetch-micron-wasm.mjs && node scripts/build-visualiser-wasm.mjs && node scripts/sync-meshchatx-docs.js",
"predev": "node scripts/sync-meshchatx-docs.js",
"build-frontend": "vite build",
"build-backend": "node scripts/build-backend.js",

View file

@ -0,0 +1,106 @@
#!/usr/bin/env node
/**
* Builds visualiser-wasm (Go) and copies artifacts into frontend public vendor/.
* Writes integrity.json with SHA-384 SRI hashes.
* Safe offline: if Go is missing, exits 0 when VISUALISER_WASM_SKIP=1.
*/
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { spawnSync } from "child_process";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..");
const GO_MOD_DIR = path.join(REPO_ROOT, "visualiser-wasm");
const OUT_DIR = path.join(REPO_ROOT, "meshchatx", "src", "frontend", "public", "vendor", "visualiser-wasm");
const WASM_NAME = "visualiser.wasm";
const EXEC_NAME = "wasm_exec.js";
const VERSION = "1.0.0";
function computeSri(buf) {
return `sha384-${crypto.createHash("sha384").update(buf).digest("base64")}`;
}
function findWasmExec() {
const fromEnv = process.env.VISUALISER_GO_WASM_EXEC;
if (fromEnv && fs.existsSync(fromEnv)) {
return fromEnv;
}
const goEnv = spawnSync("go", ["env", "GOROOT"], { encoding: "utf8" });
if (goEnv.status !== 0) {
return null;
}
const root = goEnv.stdout.trim();
const candidates = [
path.join(root, "lib", "wasm", "wasm_exec.js"),
path.join(root, "misc", "wasm", "wasm_exec.js"),
];
for (const c of candidates) {
if (fs.existsSync(c)) {
return c;
}
}
return null;
}
function main() {
if (process.env.VISUALISER_WASM_SKIP === "1") {
console.log("build-visualiser-wasm: VISUALISER_WASM_SKIP=1, skipping.");
process.exit(0);
}
if (!fs.existsSync(path.join(GO_MOD_DIR, "go.mod"))) {
console.warn("build-visualiser-wasm: visualiser-wasm/go.mod missing, skipping.");
process.exit(0);
}
const goCheck = spawnSync("go", ["version"], { encoding: "utf8" });
if (goCheck.status !== 0) {
if (process.env.MESHCHATX_OFFLINE_BUILD === "1") {
const wasmPath = path.join(OUT_DIR, WASM_NAME);
const execPath = path.join(OUT_DIR, EXEC_NAME);
if (fs.existsSync(wasmPath) && fs.existsSync(execPath)) {
console.log("build-visualiser-wasm: go missing but artifacts present (offline).");
process.exit(0);
}
console.error("build-visualiser-wasm: MESHCHATX_OFFLINE_BUILD=1 but artifacts missing and go unavailable.");
process.exit(1);
}
console.warn("build-visualiser-wasm: go not found, skipping (JS fallback will be used).");
process.exit(0);
}
fs.mkdirSync(OUT_DIR, { recursive: true });
const wasmOut = path.join(OUT_DIR, WASM_NAME);
const build = spawnSync("go", ["build", "-trimpath", "-ldflags=-s -w", "-o", wasmOut, "./cmd/wasm"], {
cwd: GO_MOD_DIR,
env: { ...process.env, GOOS: "js", GOARCH: "wasm" },
encoding: "utf8",
});
if (build.status !== 0) {
console.error(build.stderr || build.stdout || "go build failed");
process.exit(1);
}
const execSrc = findWasmExec();
if (!execSrc) {
console.error("build-visualiser-wasm: wasm_exec.js not found under GOROOT");
process.exit(1);
}
const execOut = path.join(OUT_DIR, EXEC_NAME);
fs.copyFileSync(execSrc, execOut);
const wasmBuf = fs.readFileSync(wasmOut);
const execBuf = fs.readFileSync(execOut);
const integrity = {
version: VERSION,
wasm: computeSri(wasmBuf),
wasmExec: computeSri(execBuf),
wasmExecSource: execSrc,
};
fs.writeFileSync(path.join(OUT_DIR, "integrity.json"), JSON.stringify(integrity, null, 2) + "\n");
console.log(`build-visualiser-wasm: OK (${wasmBuf.length} bytes WASM, SRI written to vendor/visualiser-wasm/)`);
}
main();

View file

@ -0,0 +1,51 @@
# SPDX-License-Identifier: 0BSD
from meshchatx.src.backend.process_resource_breakdown import (
build_resource_breakdown,
top_by_cpu,
top_by_rss,
)
class _Mem:
def __init__(self, rss):
self.rss = rss
class _FakeProc:
def __init__(self, name, rss, cpu=1.0, children=None):
self._name = name
self._rss = rss
self._cpu = cpu
self._children = children or []
self.pid = abs(hash(name)) % 100000
def name(self):
return self._name
def memory_info(self):
return _Mem(self._rss)
def cpu_percent(self, interval=None):
return self._cpu
def children(self, recursive=True):
return list(self._children)
def test_build_resource_breakdown_orders_by_rss():
child_big = _FakeProc("bot", 80_000_000, cpu=20.0)
child_small = _FakeProc("helper", 5_000_000, cpu=2.0)
parent = _FakeProc("python", 40_000_000, cpu=5.0, children=[child_big, child_small])
rows = build_resource_breakdown(parent)
assert rows[0]["name"] == "child:bot"
assert rows[0]["rss"] == 80_000_000
assert top_by_rss(rows)["name"] == "child:bot"
assert top_by_cpu(rows)["name"] == "child:bot"
def test_build_resource_breakdown_handles_none():
assert build_resource_breakdown(None) == []
assert top_by_rss([]) is None
assert top_by_cpu([]) is None

View file

@ -20,6 +20,7 @@ describe("AboutPage.vue", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
localStorage.clear();
axiosMock = {
get: vi.fn().mockImplementation(() => Promise.resolve({ data: {} })),
post: vi.fn().mockImplementation(() => Promise.resolve({ data: {} })),
@ -541,6 +542,10 @@ describe("AboutPage.vue", () => {
confidence: "estimate",
method: "cpu_time",
},
resource_breakdown: [
{ name: "backend", rss: 128 * 1024 * 1024, cpu_percent: 2.5 },
{ name: "child:bot", rss: 64 * 1024 * 1024, cpu_percent: 12.0 },
],
reticulum_stats: {
memory_cleanup: { path_table_size: 42, sqlite_relaxed: false },
},
@ -562,6 +567,12 @@ describe("AboutPage.vue", () => {
expect(wrapper.text()).toContain("about.usage_insights");
expect(wrapper.text()).toContain("about.app_battery_use");
expect(wrapper.text()).toContain("about.battery_saver");
expect(wrapper.text()).toContain("about.battery_saver_off");
expect(wrapper.text()).toContain("about.top_memory_consumer");
expect(wrapper.text()).toContain("about.top_cpu_consumer");
expect(wrapper.vm.topMemoryConsumerLabel).toContain("backend");
expect(wrapper.vm.topCpuConsumerLabel).toContain("child:bot");
expect(wrapper.vm.batteryUsageLabel).toContain("0.4%/hr");
expect(wrapper.text()).toContain("about.memory_rss");
expect(wrapper.text()).toContain("about.process_cpu");
@ -569,4 +580,37 @@ describe("AboutPage.vue", () => {
expect(wrapper.vm.showHostBattery).toBe(false);
expect(wrapper.text()).not.toContain("about.env_battery");
});
it("shows battery saver active measures when enabled", async () => {
const { saveBatterySaverPrefs } = await import("../../meshchatx/src/frontend/js/settings/batterySaverPrefs.js");
saveBatterySaverPrefs({ enabled: true });
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/app/info") {
return Promise.resolve({
data: {
app_info: {
version: "1.0.0",
host_platform: "linux",
memory_usage: { rss: 1, vms: 1 },
},
},
});
}
if (url === "/api/v1/config") return Promise.resolve({ data: { config: {} } });
if (url === "/api/v1/database/health") return Promise.resolve({ data: { database: {} } });
if (url === "/api/v1/database/snapshots") return Promise.resolve({ data: [] });
return Promise.reject(new Error("Not found"));
});
const wrapper = mountAboutPage();
await vi.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
await wrapper.vm.getAppInfo();
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("about.battery_saver_on");
expect(wrapper.text()).toContain("about.battery_saver_measures");
expect(wrapper.vm.batterySaverActiveMeasures.length).toBeGreaterThan(0);
});
});

View file

@ -157,6 +157,11 @@ describe("NetworkVisualiser.vue", () => {
"visualiser.reticulum_mesh": "Reticulum Mesh",
"visualiser.total_nodes": "Nodes",
"visualiser.total_edges": "Links",
"visualiser.nodes": "Nodes",
"visualiser.links": "Links",
"visualiser.engine_wasm": "WASM",
"visualiser.engine_fallback": "JS fallback",
"visualiser.engine_checking": "Checking",
};
return translations[msg] || msg;
},
@ -167,6 +172,7 @@ describe("NetworkVisualiser.vue", () => {
'<input type="checkbox" :checked="modelValue" @change="$emit(\'update:modelValue\', $event.target.checked)" />',
props: ["modelValue"],
},
MaterialDesignIcon: true,
},
},
});

View file

@ -0,0 +1,56 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect } from "vitest";
import NetworkVisualiserToolbar from "@/components/network-visualiser/internal/NetworkVisualiserToolbar.vue";
describe("NetworkVisualiserToolbar", () => {
const mountToolbar = (props = {}) =>
mount(NetworkVisualiserToolbar, {
props: {
isShowingControls: true,
nodeCount: 12,
edgeCount: 8,
onlineInterfaceCount: 2,
offlineInterfaceCount: 1,
engineMode: "wasm",
fps: 58,
...props,
},
global: {
mocks: { $t: (k, v) => (v ? `${k}:${JSON.stringify(v)}` : k) },
stubs: {
Toggle: true,
MaterialDesignIcon: {
props: ["iconName"],
template: `<span class="mdi-stub" :data-icon="iconName"></span>`,
},
},
},
});
it("shows WASM engine label and FPS", () => {
const wrapper = mountToolbar();
expect(wrapper.text()).toContain("visualiser.engine_wasm");
expect(wrapper.text()).toContain("58");
expect(wrapper.text()).toContain("visualiser.fps");
});
it("shows JS fallback engine label", () => {
const wrapper = mountToolbar({ engineMode: "fallback", fps: 0 });
expect(wrapper.text()).toContain("visualiser.engine_fallback");
expect(wrapper.text()).toContain("--");
});
it("uses MDI magnify for search and refresh for update button", () => {
const wrapper = mountToolbar({ isUpdating: false, isLoading: false });
const icons = wrapper.findAll(".mdi-stub").map((n) => n.attributes("data-icon"));
expect(icons).toContain("magnify");
expect(icons).toContain("refresh");
expect(icons).not.toContain(undefined);
});
it("uses loading icon while updating", () => {
const wrapper = mountToolbar({ isUpdating: true });
const icons = wrapper.findAll(".mdi-stub").map((n) => n.attributes("data-icon"));
expect(icons).toContain("loading");
});
});

View file

@ -45,6 +45,36 @@ describe("SRI (Subresource Integrity) Verification", () => {
});
});
describe("visualiser-wasm", () => {
it("has valid integrity.json with matching SRI hashes when built", () => {
const integrityPath = path.join(
REPO_ROOT,
"meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json"
);
const wasmPath = path.join(
REPO_ROOT,
"meshchatx/src/frontend/public/vendor/visualiser-wasm/visualiser.wasm"
);
const execPath = path.join(REPO_ROOT, "meshchatx/src/frontend/public/vendor/visualiser-wasm/wasm_exec.js");
if (!existsSync(integrityPath)) {
return;
}
const integrity = JSON.parse(readFileSync(integrityPath, "utf-8"));
expect(integrity.version).toBeTruthy();
expect(integrity.wasm).toMatch(/^sha384-[A-Za-z0-9+/=]+$/);
expect(integrity.wasmExec).toMatch(/^sha384-[A-Za-z0-9+/=]+$/);
if (existsSync(wasmPath)) {
expect(computeSri384(wasmPath)).toBe(integrity.wasm);
}
if (existsSync(execPath)) {
expect(computeSri384(execPath)).toBe(integrity.wasmExec);
}
});
});
describe("Codec2 Emscripten", () => {
it("has valid integrity.json with all required files", () => {
const integrityPath = path.join(

View file

@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
isVisualiserWasmBundled,
preloadVisualiserWasm,
isVisualiserWasmReady,
callVisualiserWasmJson,
} from "@/js/VisualiserWasmLoader.js";
describe("VisualiserWasmLoader", () => {
beforeEach(() => {
document.getElementById("meshchatx-visualiser-wasm-exec")?.remove();
delete globalThis.__MESHCHATX_TEST_VISUALISER_WASM_BUNDLED__;
delete globalThis.meshchatxVisualiserBuildPathGraph;
delete globalThis.meshchatxVisualiserPathHashes;
delete globalThis.meshchatxVisualiserDedupeIcons;
delete globalThis.Go;
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
document.getElementById("meshchatx-visualiser-wasm-exec")?.remove();
});
it("reports bundled flag from test override", () => {
globalThis.__MESHCHATX_TEST_VISUALISER_WASM_BUNDLED__ = false;
expect(isVisualiserWasmBundled()).toBe(false);
globalThis.__MESHCHATX_TEST_VISUALISER_WASM_BUNDLED__ = true;
expect(isVisualiserWasmBundled()).toBe(true);
});
it("preloadVisualiserWasm resolves false when not bundled", async () => {
globalThis.__MESHCHATX_TEST_VISUALISER_WASM_BUNDLED__ = false;
await expect(preloadVisualiserWasm()).resolves.toBe(false);
});
it("preloadVisualiserWasm resolves false when WebAssembly is unavailable", async () => {
globalThis.__MESHCHATX_TEST_VISUALISER_WASM_BUNDLED__ = true;
vi.stubGlobal("WebAssembly", undefined);
await expect(preloadVisualiserWasm()).resolves.toBe(false);
expect(isVisualiserWasmReady()).toBe(false);
});
it("callVisualiserWasmJson returns null when export missing", () => {
expect(callVisualiserWasmJson("meshchatxVisualiserBuildPathGraph", "{}")).toBeNull();
});
it("callVisualiserWasmJson parses JSON string results", () => {
globalThis.meshchatxVisualiserPathHashes = () => JSON.stringify(["aa", "bb"]);
expect(callVisualiserWasmJson("meshchatxVisualiserPathHashes", "[]", 4)).toEqual(["aa", "bb"]);
});
it("callVisualiserWasmJson returns null on export error object", () => {
globalThis.meshchatxVisualiserPathHashes = () => ({ ok: false, error: "bad" });
expect(callVisualiserWasmJson("meshchatxVisualiserPathHashes", "[]")).toBeNull();
});
});

View file

@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { buildBitrateApplyPayload, saveBatterySaverPrefs } from "@/js/settings/batterySaverPrefs.js";
import {
applyBatterySaverBitrateLimits,
restoreBatterySaverBitrateLimits,
} from "@/js/settings/batterySaverBitrateApply.js";
describe("batterySaverBitrateApply", () => {
beforeEach(() => {
localStorage.clear();
});
it("buildBitrateApplyPayload snapshots previous bitrates", () => {
const { bitrates, previous } = buildBitrateApplyPayload(
{ LoRa: { bitrate: "5000" }, TCP: { bitrate: null } },
{ LoRa: 1200, Missing: 100 }
);
expect(bitrates).toEqual({ LoRa: 1200 });
expect(previous).toEqual({ LoRa: 5000 });
});
it("applyBatterySaverBitrateLimits posts bitrates and reloads", async () => {
saveBatterySaverPrefs({
applyInterfaceBitrateLimits: true,
interfaceBitrateLimits: { LoRa: 1200 },
});
const api = {
get: vi.fn().mockResolvedValue({
data: { interfaces: { LoRa: { bitrate: "5000", type: "RNodeInterface" } } },
}),
post: vi.fn().mockResolvedValue({
data: { updated: ["LoRa"], reloaded: true },
}),
};
const result = await applyBatterySaverBitrateLimits({ api, reload: true });
expect(api.post).toHaveBeenCalledWith("/api/v1/reticulum/interfaces/bitrates", {
bitrates: { LoRa: 1200 },
reload: true,
});
expect(result).toEqual({ updated: ["LoRa"], reloaded: true });
});
it("restoreBatterySaverBitrateLimits restores previous map", async () => {
saveBatterySaverPrefs({
interfaceBitratePrevious: { LoRa: 5000 },
});
const api = {
post: vi.fn().mockResolvedValue({
data: { updated: ["LoRa"], reloaded: true },
}),
};
const result = await restoreBatterySaverBitrateLimits({ api, reload: true });
expect(api.post).toHaveBeenCalledWith("/api/v1/reticulum/interfaces/bitrates", {
bitrates: { LoRa: 5000 },
reload: true,
});
expect(result.updated).toEqual(["LoRa"]);
});
});

View file

@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
BATTERY_SAVER_DEFAULTS,
loadBatterySaverPrefs,
saveBatterySaverPrefs,
normalizeBatterySaverPrefs,
applyBackgroundPollInterval,
effectiveVisualiserReloadMs,
activeBatterySaverMeasures,
BATTERY_SAVER_STORAGE_KEY,
} from "@/js/settings/batterySaverPrefs.js";
describe("batterySaverPrefs", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("loads defaults when empty", () => {
const loaded = loadBatterySaverPrefs();
expect(loaded.enabled).toBe(false);
expect(loaded.applyInterfaceBitrateLimits).toBe(false);
expect(loaded.interfaceBitrateLimits).toEqual({});
});
it("round-trips prefs through localStorage", () => {
const saved = saveBatterySaverPrefs({
enabled: true,
maxVisualiserInterfaces: 4,
visualiserReloadSeconds: 90,
backgroundPollMultiplier: 4,
applyInterfaceBitrateLimits: true,
interfaceBitrateLimits: { LoRa: 1200 },
});
expect(saved.enabled).toBe(true);
expect(saved.maxVisualiserInterfaces).toBe(4);
expect(saved.interfaceBitrateLimits.LoRa).toBe(1200);
expect(JSON.parse(localStorage.getItem(BATTERY_SAVER_STORAGE_KEY)).enabled).toBe(true);
expect(loadBatterySaverPrefs().visualiserReloadSeconds).toBe(90);
});
it("clamps numeric fields", () => {
const n = normalizeBatterySaverPrefs({
enabled: true,
maxVisualiserInterfaces: 999,
backgroundPollMultiplier: 1,
visualiserReloadSeconds: -5,
interfacesStatsPollSeconds: 0,
interfacesDiscoveryPollSeconds: 1,
});
expect(n.maxVisualiserInterfaces).toBe(128);
expect(n.backgroundPollMultiplier).toBe(2);
expect(n.visualiserReloadSeconds).toBe(0);
expect(n.interfacesStatsPollSeconds).toBe(1);
expect(n.interfacesDiscoveryPollSeconds).toBe(5);
});
it("applyBackgroundPollInterval only scales when enabled", () => {
expect(applyBackgroundPollInterval(1000, { ...BATTERY_SAVER_DEFAULTS, enabled: false })).toBe(1000);
expect(
applyBackgroundPollInterval(1000, {
...BATTERY_SAVER_DEFAULTS,
enabled: true,
reduceBackgroundPolling: true,
backgroundPollMultiplier: 3,
})
).toBe(3000);
});
it("effectiveVisualiserReloadMs disables or slows auto-reload", () => {
expect(effectiveVisualiserReloadMs(15000, { ...BATTERY_SAVER_DEFAULTS, enabled: false })).toBe(15000);
expect(
effectiveVisualiserReloadMs(15000, {
...BATTERY_SAVER_DEFAULTS,
enabled: true,
visualiserReloadSeconds: 0,
})
).toBeNull();
expect(
effectiveVisualiserReloadMs(15000, {
...BATTERY_SAVER_DEFAULTS,
enabled: true,
visualiserReloadSeconds: 60,
})
).toBe(60000);
});
it("activeBatterySaverMeasures lists enabled knobs", () => {
expect(activeBatterySaverMeasures({ ...BATTERY_SAVER_DEFAULTS, enabled: false })).toEqual([]);
const measures = activeBatterySaverMeasures({
...BATTERY_SAVER_DEFAULTS,
enabled: true,
interfaceBitrateLimits: {},
interfaceBitratePrevious: {},
});
expect(measures).toContain("disableVisualiserDiscovery");
expect(measures).toContain("reduceBackgroundPolling");
expect(measures).not.toContain("applyInterfaceBitrateLimits");
expect(
activeBatterySaverMeasures({
...BATTERY_SAVER_DEFAULTS,
enabled: true,
applyInterfaceBitrateLimits: true,
interfaceBitrateLimits: { A: 1000 },
interfaceBitratePrevious: {},
})
).toContain("applyInterfaceBitrateLimits");
});
});

View file

@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
loadVisualiserCache,
saveVisualiserCache,
clearVisualiserCache,
resetVisualiserCacheDbHandle,
} from "@/js/networkVisualiserCache.js";
function mockIndexedDb() {
const stores = new Map();
const fakeDb = {
objectStoreNames: {
contains: (name) => name === "snapshots",
},
transaction(storeNames, mode) {
const storeName = Array.isArray(storeNames) ? storeNames[0] : storeNames;
if (!stores.has(storeName)) stores.set(storeName, new Map());
const data = stores.get(storeName);
const store = {
get(key) {
const req = {};
queueMicrotask(() => {
req.result = data.get(key);
req.onsuccess?.();
});
return req;
},
put(row) {
data.set(row.identityHash, row);
const req = {};
queueMicrotask(() => req.onsuccess?.());
return req;
},
delete(key) {
data.delete(key);
const req = {};
queueMicrotask(() => req.onsuccess?.());
return req;
},
};
const tx = {
objectStore: () => store,
oncomplete: null,
onerror: null,
};
queueMicrotask(() => tx.oncomplete?.());
return tx;
},
};
const idb = {
open() {
const req = {
result: fakeDb,
onupgradeneeded: null,
onsuccess: null,
onerror: null,
};
queueMicrotask(() => {
req.onupgradeneeded?.({ target: req });
req.onsuccess?.({ target: req });
});
return req;
},
};
return idb;
}
describe("networkVisualiserCache", () => {
beforeEach(() => {
resetVisualiserCacheDbHandle();
vi.stubGlobal("indexedDB", mockIndexedDb());
});
afterEach(() => {
resetVisualiserCacheDbHandle();
vi.unstubAllGlobals();
});
it("round-trips path table announces and positions for one identity", async () => {
const ok = await saveVisualiserCache({
identityHash: "abc123",
pathTable: [{ hash: "n1", hops: 1, interface: "eth0" }],
announces: { n1: { destination_hash: "n1", aspect: "lxmf.delivery" } },
positions: { n1: { x: 10, y: 20 } },
});
expect(ok).toBe(true);
const loaded = await loadVisualiserCache("abc123");
expect(loaded.pathTable).toHaveLength(1);
expect(loaded.announces.n1.aspect).toBe("lxmf.delivery");
expect(loaded.positions.n1).toEqual({ x: 10, y: 20 });
});
it("isolates cache by identity hash", async () => {
await saveVisualiserCache({
identityHash: "id-a",
pathTable: [{ hash: "a" }],
announces: {},
positions: {},
});
await saveVisualiserCache({
identityHash: "id-b",
pathTable: [{ hash: "b" }],
announces: {},
positions: {},
});
expect((await loadVisualiserCache("id-a")).pathTable[0].hash).toBe("a");
expect((await loadVisualiserCache("id-b")).pathTable[0].hash).toBe("b");
});
it("clearVisualiserCache removes a snapshot", async () => {
await saveVisualiserCache({
identityHash: "gone",
pathTable: [{ hash: "x" }],
announces: {},
positions: {},
});
await clearVisualiserCache("gone");
expect(await loadVisualiserCache("gone")).toBeNull();
});
it("returns null when IndexedDB is unavailable", async () => {
resetVisualiserCacheDbHandle();
vi.stubGlobal("indexedDB", undefined);
expect(await loadVisualiserCache("x")).toBeNull();
expect(await saveVisualiserCache({ identityHash: "x", pathTable: [], announces: {}, positions: {} })).toBe(
false
);
});
});

View file

@ -1,13 +1,30 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
ANNOUNCE_HASH_CHUNK_SIZE,
VIZ_ANNOUNCE_ASPECTS,
buildPathGraph,
buildPathGraphJs,
computeLodUpdatesJs,
dedupeIconQueueEntries,
dedupeIconQueueEntriesJs,
lodLevelFromScale,
pathHashesWithinHopFilter,
pathHashesWithinHopFilterJs,
pickAdaptiveFetchConcurrency,
} from "@/js/networkVisualiserPerf.js";
describe("networkVisualiserPerf", () => {
beforeEach(() => {
delete globalThis.meshchatxVisualiserPathHashes;
delete globalThis.meshchatxVisualiserDedupeIcons;
delete globalThis.meshchatxVisualiserBuildPathGraph;
delete globalThis.meshchatxVisualiserLODLevel;
});
afterEach(() => {
vi.restoreAllMocks();
});
it("exports visualiser constants", () => {
expect(VIZ_ANNOUNCE_ASPECTS).toEqual(["lxmf.delivery", "nomadnetwork.node"]);
expect(ANNOUNCE_HASH_CHUNK_SIZE).toBe(500);
@ -20,7 +37,7 @@ describe("networkVisualiserPerf", () => {
{ hash: "cc", hops: 5 },
{ hash: "dd", hops: null },
];
expect(pathHashesWithinHopFilter(pathTable, 4).sort()).toEqual(["aa", "bb"]);
expect(pathHashesWithinHopFilterJs(pathTable, 4).sort()).toEqual(["aa", "bb"]);
expect(pathHashesWithinHopFilter(pathTable, null).sort()).toEqual(["aa", "bb", "cc"]);
});
@ -30,13 +47,72 @@ describe("networkVisualiserPerf", () => {
{ nodeId: "n2", cacheKey: "k1", iconName: "a", fg: "#000", bg: "#fff", size: 64, generation: 1 },
{ nodeId: "n3", cacheKey: "k2", iconName: "b", fg: "#111", bg: "#eee", size: 64, generation: 1 },
];
const out = dedupeIconQueueEntries(queue);
const out = dedupeIconQueueEntriesJs(queue);
expect(out).toHaveLength(2);
expect(out.find((x) => x.cacheKey === "k1")?.nodeIds).toEqual(["n1", "n2"]);
expect(out.every((x) => x._seen === undefined)).toBe(true);
expect(dedupeIconQueueEntries(queue)).toHaveLength(2);
});
it("pickAdaptiveFetchConcurrency returns a positive integer", () => {
expect(pickAdaptiveFetchConcurrency()).toBeGreaterThanOrEqual(2);
});
it("buildPathGraphJs filters hops and builds nodes/edges", () => {
const res = buildPathGraphJs({
path_table: [
{ hash: "aa", interface: "eth0", hops: 1 },
{ hash: "bb", interface: "eth0", hops: 9 },
],
announces: {
aa: {
destination_hash: "aa",
aspect: "lxmf.delivery",
display_name: "Alice",
last_seen: "now",
},
bb: {
destination_hash: "bb",
aspect: "lxmf.delivery",
display_name: "Far",
last_seen: "now",
},
},
positions: { eth0: { x: 10, y: 20 } },
hop_max: 4,
dark_mode: false,
lod: "high",
});
expect(res.nodes).toHaveLength(1);
expect(res.edges).toHaveLength(1);
expect(res.nodes[0].id).toBe("aa");
expect(res.edges[0].width).toBe(2.5);
expect(buildPathGraph({ path_table: [], announces: {} }).nodes).toEqual([]);
});
it("computeLodUpdatesJs and lodLevelFromScale work without WASM", () => {
expect(lodLevelFromScale(0.1)).toBe("low");
expect(lodLevelFromScale(0.3)).toBe("medium");
expect(lodLevelFromScale(0.8)).toBe("high");
const updates = computeLodUpdatesJs(
[{ id: "n1", shape: "circularImage", size: 25, _originalShape: "circularImage", _originalSize: 25 }],
"low",
false
);
expect(updates[0].shape).toBe("dot");
});
it("falls back to JS when WASM export throws", () => {
globalThis.meshchatxVisualiserPathHashes = () => {
throw new Error("boom");
};
globalThis.meshchatxVisualiserBuildPathGraph = () => {
throw new Error("boom");
};
globalThis.meshchatxVisualiserDedupeIcons = () => {
throw new Error("boom");
};
expect(pathHashesWithinHopFilter([{ hash: "aa", hops: 1 }], 4)).toEqual(["aa"]);
expect(dedupeIconQueueEntries([])).toEqual([]);
expect(buildPathGraph({ path_table: [], announces: {} }).nodes).toEqual([]);
});
});

View file

@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { mergeResourceBreakdown, topResourceByCpu, topResourceByRss } from "@/js/resourceBreakdown.js";
describe("resourceBreakdown", () => {
it("merges electron private memory as bytes", () => {
const rows = mergeResourceBreakdown([{ name: "backend", rss: 10_000_000, cpu_percent: 3 }], { private: 2048 });
expect(rows).toHaveLength(2);
expect(rows[1]).toEqual({ name: "electron", rss: 2048 * 1024, cpu_percent: null });
expect(topResourceByRss(rows)?.name).toBe("backend");
expect(topResourceByCpu(rows)?.name).toBe("backend");
});
it("picks top cpu when present", () => {
const rows = mergeResourceBreakdown(
[
{ name: "backend", rss: 10, cpu_percent: 1 },
{ name: "child:bot", rss: 5, cpu_percent: 40 },
],
null
);
expect(topResourceByCpu(rows)?.name).toBe("child:bot");
expect(topResourceByRss(rows)?.name).toBe("backend");
});
});

View file

@ -26,6 +26,7 @@ const KNOWN_SECTIONS_FROM_SETTINGS_PAGE = [
"nomadRenderer",
"crawler",
"appearance",
"battery",
"visualiser",
"location",
"language",
@ -66,6 +67,7 @@ describe("settingsTabs", () => {
it("maps sections to tabs", () => {
expect(settingsTabForSection("appearance")).toBe("general");
expect(settingsTabForSection("battery")).toBe("general");
expect(settingsTabForSection("location")).toBe("general");
expect(settingsTabForSection("messages")).toBe("messages");
expect(settingsTabForSection("archiver")).toBe("nomad");

View file

@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
loadVisualiserDisplayPrefs,
persistVisualiserLiveLayout,
persistVisualiserAutoReload,
persistVisualiserShowDisabled,
persistVisualiserShowDiscovered,
} from "@/js/settings/settingsVisualiserPrefs.js";
describe("settingsVisualiserPrefs", () => {
beforeEach(() => {
localStorage.clear();
});
it("defaults live layout on and auto-reload off", () => {
expect(loadVisualiserDisplayPrefs()).toEqual({
showDisabledInterfaces: false,
showDiscoveredInterfaces: false,
enablePhysics: true,
autoReload: false,
});
});
it("persists live layout and auto-reload across loads", () => {
persistVisualiserLiveLayout(false);
persistVisualiserAutoReload(true);
persistVisualiserShowDisabled(true);
persistVisualiserShowDiscovered(true);
expect(loadVisualiserDisplayPrefs()).toEqual({
showDisabledInterfaces: true,
showDiscoveredInterfaces: true,
enablePhysics: false,
autoReload: true,
});
persistVisualiserLiveLayout(true);
expect(loadVisualiserDisplayPrefs().enablePhysics).toBe(true);
});
it("can persist live layout without emitting rebuild events", () => {
persistVisualiserLiveLayout(false, { emit: false });
expect(loadVisualiserDisplayPrefs().enablePhysics).toBe(false);
persistVisualiserAutoReload(true, { emit: false });
expect(loadVisualiserDisplayPrefs().autoReload).toBe(true);
});
});

View file

@ -0,0 +1,140 @@
// SPDX-License-Identifier: 0BSD
// Command wasm exposes network visualiser hot-path helpers to the browser.
package main
import (
"encoding/json"
"syscall/js"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/filter"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/graph"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/icon"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/layout"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/lod"
)
const apiVersion = "1.1.0"
func main() {
js.Global().Set("meshchatxVisualiserVersion", apiVersion)
js.Global().Set("meshchatxVisualiserPathHashes", js.FuncOf(wrapJSON(pathHashesHandler)))
js.Global().Set("meshchatxVisualiserDedupeIcons", js.FuncOf(wrapJSON(dedupeIconsHandler)))
js.Global().Set("meshchatxVisualiserBuildPathGraph", js.FuncOf(wrapJSON(buildPathGraphHandler)))
js.Global().Set("meshchatxVisualiserBuildFullGraph", js.FuncOf(wrapJSON(buildFullGraphHandler)))
js.Global().Set("meshchatxVisualiserLayout", js.FuncOf(wrapJSON(layoutHandler)))
js.Global().Set("meshchatxVisualiserLODUpdates", js.FuncOf(wrapJSON(lodUpdatesHandler)))
js.Global().Set("meshchatxVisualiserLODLevel", js.FuncOf(lodLevelHandler))
select {}
}
type handlerFunc func(args []js.Value) (any, error)
func wrapJSON(fn handlerFunc) func(js.Value, []js.Value) any {
return func(_ js.Value, args []js.Value) any {
out, err := fn(args)
if err != nil {
return js.ValueOf(map[string]any{
"ok": false,
"error": err.Error(),
})
}
buf, err := json.Marshal(out)
if err != nil {
return js.ValueOf(map[string]any{
"ok": false,
"error": err.Error(),
})
}
return js.ValueOf(string(buf))
}
}
func readJSONArg(args []js.Value, idx int, dest any) error {
if idx >= len(args) {
return errMissingArg
}
raw := args[idx].String()
return json.Unmarshal([]byte(raw), dest)
}
var errMissingArg = errString("missing json argument")
type errString string
func (e errString) Error() string { return string(e) }
func pathHashesHandler(args []js.Value) (any, error) {
var pathTable []filter.PathEntry
if err := readJSONArg(args, 0, &pathTable); err != nil {
return nil, err
}
var hopMax *float64
if len(args) > 1 && !args[1].IsNull() && !args[1].IsUndefined() {
if args[1].Type() == js.TypeNumber {
v := args[1].Float()
hopMax = &v
} else if args[1].Type() == js.TypeString {
s := args[1].String()
if s != "" && s != "null" {
var v float64
if err := json.Unmarshal([]byte(s), &v); err == nil {
hopMax = &v
}
}
}
}
return filter.PathHashesWithinHopFilter(pathTable, hopMax), nil
}
func dedupeIconsHandler(args []js.Value) (any, error) {
var queue []icon.QueueItem
if err := readJSONArg(args, 0, &queue); err != nil {
return nil, err
}
return icon.DedupeQueueEntries(queue), nil
}
func buildPathGraphHandler(args []js.Value) (any, error) {
var req graph.Request
if err := readJSONArg(args, 0, &req); err != nil {
return nil, err
}
return graph.BuildPathGraph(req), nil
}
func buildFullGraphHandler(args []js.Value) (any, error) {
var req graph.FullRequest
if err := readJSONArg(args, 0, &req); err != nil {
return nil, err
}
return graph.BuildFullGraph(req), nil
}
func layoutHandler(args []js.Value) (any, error) {
var req layout.Request
if err := readJSONArg(args, 0, &req); err != nil {
return nil, err
}
return layout.Settle(req), nil
}
func lodUpdatesHandler(args []js.Value) (any, error) {
var payload struct {
Nodes []lod.NodeIn `json:"nodes"`
LOD string `json:"lod"`
DarkMode bool `json:"dark_mode"`
}
if err := readJSONArg(args, 0, &payload); err != nil {
return nil, err
}
return lod.ComputeUpdates(payload.Nodes, payload.LOD, payload.DarkMode), nil
}
func lodLevelHandler(_ js.Value, args []js.Value) any {
if len(args) == 0 || args[0].Type() != js.TypeNumber {
return "high"
}
return lod.LevelFromScale(args[0].Float())
}

5
visualiser-wasm/go.mod Normal file
View file

@ -0,0 +1,5 @@
// SPDX-License-Identifier: 0BSD
module github.com/Quad4-Software/MeshChatX/visualiser-wasm
go 1.22

View file

@ -0,0 +1,90 @@
// SPDX-License-Identifier: 0BSD
// Package filter implements hop and search filters for the network visualiser.
package filter
import "strings"
// PathEntry is a compact path-table row.
type PathEntry struct {
Hash string `json:"hash"`
Interface string `json:"interface"`
Hops *float64 `json:"hops"`
}
// PathHashesWithinHopFilter returns unique destination hashes within hopMax.
// A nil hopMax means no hop ceiling. Rows with nil hops are skipped.
func PathHashesWithinHopFilter(pathTable []PathEntry, hopMax *float64) []string {
if len(pathTable) == 0 {
return nil
}
seen := make(map[string]struct{}, len(pathTable)/2+1)
out := make([]string, 0, len(pathTable)/4+1)
for i := range pathTable {
e := &pathTable[i]
if e.Hops == nil || e.Hash == "" {
continue
}
if hopMax != nil && *e.Hops > *hopMax {
continue
}
if _, ok := seen[e.Hash]; ok {
continue
}
seen[e.Hash] = struct{}{}
out = append(out, e.Hash)
}
return out
}
// MatchesSearch reports whether text matches queryLower (already lowercased).
// An empty query matches everything.
// ASCII-only text avoids allocating a lowered copy.
func MatchesSearch(queryLower, text string) bool {
if queryLower == "" {
return true
}
if text == "" {
return false
}
if isASCII(text) {
return containsFoldASCII(text, queryLower)
}
return strings.Contains(strings.ToLower(text), queryLower)
}
func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= 0x80 {
return false
}
}
return true
}
func containsFoldASCII(haystack, needleLower string) bool {
n := len(needleLower)
if n == 0 {
return true
}
if n > len(haystack) {
return false
}
for i := 0; i+n <= len(haystack); i++ {
ok := true
for j := 0; j < n; j++ {
c := haystack[i+j]
if c >= 'A' && c <= 'Z' {
c += 'a' - 'A'
}
if c != needleLower[j] {
ok = false
break
}
}
if ok {
return true
}
}
return false
}

View file

@ -0,0 +1,148 @@
// SPDX-License-Identifier: 0BSD
package filter_test
import (
"sync"
"testing"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/filter"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/leaktest"
)
func ptr(v float64) *float64 { return &v }
func TestPathHashesWithinHopFilter(t *testing.T) {
table := []filter.PathEntry{
{Hash: "aa", Hops: ptr(1)},
{Hash: "bb", Hops: ptr(4)},
{Hash: "cc", Hops: ptr(5)},
{Hash: "dd", Hops: nil},
{Hash: "aa", Hops: ptr(1)},
}
got := filter.PathHashesWithinHopFilter(table, ptr(4))
if len(got) != 2 || got[0] != "aa" || got[1] != "bb" {
t.Fatalf("unexpected: %#v", got)
}
gotAll := filter.PathHashesWithinHopFilter(table, nil)
if len(gotAll) != 3 {
t.Fatalf("expected 3 unique hashes, got %#v", gotAll)
}
if filter.PathHashesWithinHopFilter(nil, ptr(1)) != nil {
t.Fatal("empty should return nil")
}
}
func TestMatchesSearch(t *testing.T) {
if !filter.MatchesSearch("", "anything") {
t.Fatal("empty query should match")
}
if !filter.MatchesSearch("foo", "FooBar") {
t.Fatal("expected case-insensitive match")
}
if filter.MatchesSearch("zzz", "abc") {
t.Fatal("expected miss")
}
if !filter.MatchesSearch("café", "Café Latte") {
t.Fatal("expected unicode fold match")
}
}
func TestFilterNoGoroutineLeak(t *testing.T) {
defer leaktest.Check(t)()
table := make([]filter.PathEntry, 200)
for i := range table {
h := float64(i % 8)
table[i] = filter.PathEntry{Hash: "h" + string(rune('a'+i%26)), Hops: &h}
}
for i := 0; i < 200; i++ {
_ = filter.PathHashesWithinHopFilter(table, ptr(4))
_ = filter.MatchesSearch("ab", "Alphabet")
}
}
func TestFilterRace(t *testing.T) {
table := make([]filter.PathEntry, 500)
for i := range table {
h := float64(i%6 + 1)
table[i] = filter.PathEntry{Hash: "hash-" + string(rune('0'+i%10)), Interface: "eth0", Hops: &h}
}
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
_ = filter.PathHashesWithinHopFilter(table, ptr(3))
_ = filter.MatchesSearch("hash", "HASH-1")
}
}()
}
wg.Wait()
}
func FuzzPathHashesWithinHopFilter(f *testing.F) {
f.Add("aa", float64(1), float64(4))
f.Add("", float64(0), float64(0))
f.Fuzz(func(t *testing.T, hash string, hops, hopMax float64) {
table := []filter.PathEntry{{Hash: hash, Hops: &hops}}
out := filter.PathHashesWithinHopFilter(table, &hopMax)
if hops > hopMax {
if len(out) != 0 {
t.Fatalf("expected empty for hops>%v", hopMax)
}
return
}
if hash == "" {
if len(out) != 0 {
t.Fatal("empty hash should be skipped")
}
return
}
if len(out) != 1 || out[0] != hash {
t.Fatalf("unexpected %#v", out)
}
})
}
func FuzzMatchesSearch(f *testing.F) {
f.Add("foo", "FooBar")
f.Add("", "x")
f.Add("z", "")
f.Fuzz(func(t *testing.T, q, text string) {
_ = filter.MatchesSearch(q, text)
if q == "" && !filter.MatchesSearch(q, text) {
t.Fatal("empty query must match")
}
})
}
func BenchmarkPathHashesWithinHopFilter(b *testing.B) {
table := make([]filter.PathEntry, 2000)
for i := range table {
h := float64(i%8 + 1)
table[i] = filter.PathEntry{Hash: "h" + string(rune('a'+i%26)) + string(rune('0'+i%10)), Hops: &h}
}
max := 4.0
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = filter.PathHashesWithinHopFilter(table, &max)
}
}
func BenchmarkMatchesSearchASCII(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = filter.MatchesSearch("mesh", "ReticulumMeshNode")
}
}
func TestMatchesSearchASCIIZeroAllocs(t *testing.T) {
allocs := testing.AllocsPerRun(1000, func() {
_ = filter.MatchesSearch("mesh", "ReticulumMeshNode")
})
if allocs != 0 {
t.Fatalf("expected 0 allocs for ASCII search, got %v", allocs)
}
}

View file

@ -0,0 +1,407 @@
// SPDX-License-Identifier: 0BSD
package graph
import (
"strconv"
"strings"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/filter"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/hashpos"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/icon"
)
// XY is a 2D position.
type XY struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
// UserIcon is an lxmf custom icon descriptor.
type UserIcon struct {
IconName string `json:"icon_name"`
ForegroundColour string `json:"foreground_colour"`
BackgroundColour string `json:"background_colour"`
}
// Announce is the announce fields needed for graph build.
type Announce struct {
DestinationHash string `json:"destination_hash"`
Aspect string `json:"aspect"`
DisplayName string `json:"display_name"`
CustomDisplayName string `json:"custom_display_name"`
IdentityHash string `json:"identity_hash"`
LastSeen string `json:"last_seen"`
}
// Conversation carries optional lxmf user icon data.
type Conversation struct {
LxmfUserIcon *UserIcon `json:"lxmf_user_icon"`
}
// Request is the buildPathGraph input payload.
type Request struct {
PathTable []filter.PathEntry `json:"path_table"`
Announces map[string]Announce `json:"announces"`
Conversations map[string]Conversation `json:"conversations"`
IconCache map[string]string `json:"icon_cache"`
Positions map[string]XY `json:"positions"`
HopMax *float64 `json:"hop_max"`
Search string `json:"search"`
DarkMode bool `json:"dark_mode"`
LOD string `json:"lod"`
Aspects []string `json:"aspects"`
QueueIcons bool `json:"queue_icons"`
IconGeneration int `json:"icon_generation"`
}
// NodeOut is one announce node for vis-network.
type NodeOut struct {
ID string `json:"id"`
Group string `json:"group"`
Size float64 `json:"size"`
OriginalSize float64 `json:"_originalSize"`
Shape string `json:"shape"`
OriginalShape string `json:"_originalShape"`
Image string `json:"image,omitempty"`
Label string `json:"label"`
Title string `json:"title"`
Font map[string]any `json:"font"`
Color map[string]any `json:"color"`
X float64 `json:"x"`
Y float64 `json:"y"`
ParentInterface string `json:"_parentInterface,omitempty"`
}
// EdgeOut is one path edge for vis-network.
type EdgeOut struct {
ID string `json:"id"`
From string `json:"from"`
To string `json:"to"`
Color map[string]any `json:"color"`
Width float64 `json:"width"`
Hidden bool `json:"hidden"`
}
// Result is the buildPathGraph output payload.
type Result struct {
Nodes []NodeOut `json:"nodes"`
Edges []EdgeOut `json:"edges"`
IconQueue []icon.QueueItem `json:"icon_queue"`
ProcessedNodeIDs []string `json:"processed_node_ids"`
ProcessedEdgeIDs []string `json:"processed_edge_ids"`
}
var defaultAspects = []string{"lxmf.delivery", "nomadnetwork.node"}
// Shared immutable style maps reused across nodes and edges to cut allocs.
var (
fontHighLight = map[string]any{"color": "#ffffff", "size": 11.0}
fontHighDark = map[string]any{"color": "#000000", "size": 11.0}
fontHidden = map[string]any{"size": 0.0}
colorLxmfDirectLight = nodeColor("#10b981", "#ecfdf5")
colorLxmfDirectDark = nodeColor("#10b981", "#064e3b")
colorLxmfMultiLight = nodeColor("#3b82f6", "#eff6ff")
colorLxmfMultiDark = nodeColor("#3b82f6", "#1e40af")
colorNomadDirectLight = nodeColor("#10b981", "#ecfdf5")
colorNomadDirectDark = nodeColor("#10b981", "#064e3b")
colorNomadMultiLight = nodeColor("#8b5cf6", "#f5f3ff")
colorNomadMultiDark = nodeColor("#8b5cf6", "#4c1d95")
edgeDirectLight = map[string]any{"color": "#10b981", "opacity": 1.0}
edgeDirectDark = map[string]any{"color": "#34d399", "opacity": 1.0}
edgeMultiLight = map[string]any{"color": "#3b82f6", "opacity": 0.5}
edgeMultiDark = map[string]any{"color": "#60a5fa", "opacity": 0.5}
imgUser1Hop = "/assets/images/network-visualiser/user_1hop.png"
imgUser = "/assets/images/network-visualiser/user.png"
imgServer1Hop = "/assets/images/network-visualiser/server_1hop.png"
imgServer = "/assets/images/network-visualiser/server.png"
)
// BuildPathGraph constructs announce nodes and edges from the path table.
func BuildPathGraph(req Request) Result {
aspects := req.Aspects
if len(aspects) == 0 {
aspects = defaultAspects
}
aspectSet := make(map[string]struct{}, len(aspects))
for _, a := range aspects {
aspectSet[a] = struct{}{}
}
searchLower := strings.ToLower(req.Search)
fontHigh := fontHighDark
if req.DarkMode {
fontHigh = fontHighLight
}
est := len(req.PathTable)
if est > 4096 {
est = 4096
}
nodes := make([]NodeOut, 0, est/2+1)
edges := make([]EdgeOut, 0, est/2+1)
iconQueue := make([]icon.QueueItem, 0, 32)
nodeIDs := make([]string, 0, est/2+1)
edgeIDs := make([]string, 0, est/2+1)
pos := req.Positions
if pos == nil {
pos = map[string]XY{}
}
cache := req.IconCache
if cache == nil {
cache = map[string]string{}
}
announces := req.Announces
if announces == nil {
announces = map[string]Announce{}
}
conversations := req.Conversations
if conversations == nil {
conversations = map[string]Conversation{}
}
for i := range req.PathTable {
entry := &req.PathTable[i]
if entry.Hops == nil || entry.Hash == "" {
continue
}
if req.HopMax != nil && *entry.Hops > *req.HopMax {
continue
}
announce, ok := announces[entry.Hash]
if !ok {
continue
}
if _, allow := aspectSet[announce.Aspect]; !allow {
continue
}
displayName := announce.CustomDisplayName
if displayName == "" {
displayName = announce.DisplayName
}
if !filter.MatchesSearch(searchLower, displayName) &&
!filter.MatchesSearch(searchLower, announce.DestinationHash) &&
!filter.MatchesSearch(searchLower, announce.IdentityHash) {
continue
}
x, y := resolvePosition(entry.Hash, entry.Interface, pos)
edgeID := entry.Interface + "~" + entry.Hash
direct := *entry.Hops == 1
node := NodeOut{
ID: entry.Hash,
Group: "announce",
Size: 25,
OriginalSize: 25,
Label: displayName,
Title: buildTitle(displayName, announce.Aspect, *entry.Hops, entry.Interface, announce.LastSeen),
Font: fontHigh,
X: x,
Y: y,
ParentInterface: entry.Interface,
}
conv := conversations[announce.DestinationHash]
switch announce.Aspect {
case "lxmf.delivery":
applyLxmfNode(&node, &conv, direct, req.DarkMode, req.QueueIcons, req.IconGeneration, cache, &iconQueue)
case "nomadnetwork.node":
applyNomadNode(&node, direct, req.DarkMode)
}
applyLOD(&node, req.LOD, fontHigh)
nodes = append(nodes, node)
nodeIDs = append(nodeIDs, node.ID)
edges = append(edges, EdgeOut{
ID: edgeID,
From: entry.Interface,
To: entry.Hash,
Color: edgeColor(direct, req.DarkMode),
Width: edgeWidth(direct),
Hidden: false,
})
edgeIDs = append(edgeIDs, edgeID)
}
return Result{
Nodes: nodes,
Edges: edges,
IconQueue: iconQueue,
ProcessedNodeIDs: nodeIDs,
ProcessedEdgeIDs: edgeIDs,
}
}
func resolvePosition(hash, iface string, pos map[string]XY) (float64, float64) {
if prev, ok := pos[hash]; ok {
return prev.X, prev.Y
}
if ip, ok := pos[iface]; ok {
x, y := hashpos.Around(hash, ip.X, ip.Y, 150, 150)
pos[hash] = XY{X: x, Y: y}
return x, y
}
x, y := hashpos.XY(hash, 600, 200)
pos[hash] = XY{X: x, Y: y}
return x, y
}
func buildTitle(displayName, aspect string, hops float64, via, lastSeen string) string {
var b strings.Builder
b.Grow(96 + len(displayName) + len(aspect) + len(via) + len(lastSeen))
b.WriteString(displayName)
b.WriteByte('\n')
b.WriteString("Aspect: ")
b.WriteString(aspect)
b.WriteByte('\n')
b.WriteString("Hops: ")
b.WriteString(trimFloat(hops))
b.WriteByte('\n')
b.WriteString("Via: ")
b.WriteString(via)
b.WriteByte('\n')
b.WriteString("Last Seen: ")
b.WriteString(lastSeen)
return b.String()
}
func trimFloat(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
}
func applyLxmfNode(
node *NodeOut,
conv *Conversation,
direct, darkMode, queueIcons bool,
generation int,
cache map[string]string,
iconQueue *[]icon.QueueItem,
) {
node.Shape = "circularImage"
node.OriginalShape = "circularImage"
if conv != nil && conv.LxmfUserIcon != nil {
ic := conv.LxmfUserIcon
cacheKey := ic.IconName + "-" + ic.ForegroundColour + "-" + ic.BackgroundColour + "-64"
if url := cache[cacheKey]; url != "" {
node.Image = url
} else {
if direct {
node.Image = imgUser1Hop
} else {
node.Image = imgUser
}
if queueIcons {
*iconQueue = append(*iconQueue, icon.QueueItem{
NodeID: node.ID,
CacheKey: cacheKey,
IconName: ic.IconName,
FG: ic.ForegroundColour,
BG: ic.BackgroundColour,
Size: 64,
Generation: generation,
})
}
}
node.Size = 30
node.OriginalSize = 30
} else if direct {
node.Image = imgUser1Hop
} else {
node.Image = imgUser
}
if direct {
if darkMode {
node.Color = colorLxmfDirectDark
} else {
node.Color = colorLxmfDirectLight
}
} else if darkMode {
node.Color = colorLxmfMultiDark
} else {
node.Color = colorLxmfMultiLight
}
}
func applyNomadNode(node *NodeOut, direct, darkMode bool) {
node.Shape = "circularImage"
node.OriginalShape = "circularImage"
if direct {
node.Image = imgServer1Hop
} else {
node.Image = imgServer
}
if direct {
if darkMode {
node.Color = colorNomadDirectDark
} else {
node.Color = colorNomadDirectLight
}
} else if darkMode {
node.Color = colorNomadMultiDark
} else {
node.Color = colorNomadMultiLight
}
}
func applyLOD(node *NodeOut, level string, fontHigh map[string]any) {
switch level {
case "low":
node.Shape = "dot"
if node.ID == "me" {
node.Size = 15
} else {
node.Size = 10
}
node.Font = fontHidden
case "medium":
node.Shape = node.OriginalShape
node.Size = node.OriginalSize
node.Font = fontHidden
default:
node.Shape = node.OriginalShape
node.Size = node.OriginalSize
node.Font = fontHigh
}
}
func edgeColor(direct, darkMode bool) map[string]any {
if direct {
if darkMode {
return edgeDirectDark
}
return edgeDirectLight
}
if darkMode {
return edgeMultiDark
}
return edgeMultiLight
}
func edgeWidth(direct bool) float64 {
if direct {
return 2.5
}
return 1
}
func nodeColor(border, background string) map[string]any {
return map[string]any{
"border": border,
"background": background,
"highlight": map[string]any{
"border": border,
"background": background,
},
"hover": map[string]any{
"border": border,
"background": background,
},
}
}

View file

@ -0,0 +1,196 @@
// SPDX-License-Identifier: 0BSD
package graph_test
import (
"fmt"
"runtime"
"sync"
"testing"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/filter"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/graph"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/leaktest"
)
func hops(v float64) *float64 { return &v }
func sampleRequest(n int) graph.Request {
path := make([]filter.PathEntry, n)
ann := make(map[string]graph.Announce, n)
for i := 0; i < n; i++ {
hash := fmt.Sprintf("%032x", i)
h := float64(i%5 + 1)
path[i] = filter.PathEntry{Hash: hash, Interface: "eth0", Hops: &h}
aspect := "lxmf.delivery"
if i%3 == 0 {
aspect = "nomadnetwork.node"
}
ann[hash] = graph.Announce{
DestinationHash: hash,
Aspect: aspect,
DisplayName: "Node" + hash[:4],
LastSeen: "now",
}
}
return graph.Request{
PathTable: path,
Announces: ann,
Positions: map[string]graph.XY{"eth0": {X: 100, Y: 200}},
HopMax: hops(4),
DarkMode: true,
LOD: "high",
}
}
func TestBuildPathGraphFiltersAndBuilds(t *testing.T) {
req := graph.Request{
PathTable: []filter.PathEntry{
{Hash: "aa", Interface: "eth0", Hops: hops(1)},
{Hash: "bb", Interface: "eth0", Hops: hops(3)},
{Hash: "cc", Interface: "eth0", Hops: hops(9)},
{Hash: "dd", Interface: "eth0", Hops: nil},
},
Announces: map[string]graph.Announce{
"aa": {DestinationHash: "aa", Aspect: "lxmf.delivery", DisplayName: "Alice", LastSeen: "now"},
"bb": {DestinationHash: "bb", Aspect: "nomadnetwork.node", DisplayName: "Bob", LastSeen: "now"},
"cc": {DestinationHash: "cc", Aspect: "lxmf.delivery", DisplayName: "Far", LastSeen: "now"},
},
Positions: map[string]graph.XY{
"eth0": {X: 100, Y: 200},
},
HopMax: hops(4),
DarkMode: true,
LOD: "high",
}
res := graph.BuildPathGraph(req)
if len(res.Nodes) != 2 || len(res.Edges) != 2 {
t.Fatalf("expected 2 nodes/edges, got %d/%d", len(res.Nodes), len(res.Edges))
}
if res.Nodes[0].Image != "/assets/images/network-visualiser/user_1hop.png" {
t.Fatalf("unexpected lxmf image: %s", res.Nodes[0].Image)
}
if res.Edges[0].Width != 2.5 {
t.Fatalf("direct edge width: %v", res.Edges[0].Width)
}
}
func TestBuildPathGraphSearchAndIcons(t *testing.T) {
req := sampleRequest(20)
req.Search = "node0000"
req.QueueIcons = true
req.Conversations = map[string]graph.Conversation{
req.PathTable[0].Hash: {
LxmfUserIcon: &graph.UserIcon{IconName: "account", ForegroundColour: "#000", BackgroundColour: "#fff"},
},
}
res := graph.BuildPathGraph(req)
if len(res.Nodes) == 0 {
t.Fatal("expected search hits")
}
}
func TestGraphNoGoroutineLeak(t *testing.T) {
defer leaktest.Check(t)()
req := sampleRequest(200)
for i := 0; i < 50; i++ {
_ = graph.BuildPathGraph(req)
}
}
func TestGraphRace(t *testing.T) {
req := sampleRequest(300)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
local := req
local.Positions = map[string]graph.XY{"eth0": {X: 1, Y: 2}}
for j := 0; j < 20; j++ {
_ = graph.BuildPathGraph(local)
}
}()
}
wg.Wait()
}
func TestGraphHeapStableAcrossRuns(t *testing.T) {
req := sampleRequest(500)
runtime.GC()
var start, end runtime.MemStats
runtime.ReadMemStats(&start)
for i := 0; i < 30; i++ {
_ = graph.BuildPathGraph(req)
}
runtime.GC()
runtime.ReadMemStats(&end)
// Allow generous headroom for allocator noise while catching runaway retention.
const maxGrowth = 32 << 20
if end.HeapAlloc > start.HeapAlloc+maxGrowth {
t.Fatalf("heap grew too much: start=%d end=%d", start.HeapAlloc, end.HeapAlloc)
}
}
func FuzzBuildPathGraph(f *testing.F) {
f.Add("aa", float64(1), "lxmf.delivery", "Alice", float64(4), "ali")
f.Add("bb", float64(9), "nomadnetwork.node", "Bob", float64(2), "")
f.Fuzz(func(t *testing.T, hash string, hopsVal float64, aspect, name string, hopMax float64, search string) {
if hash == "" {
return
}
req := graph.Request{
PathTable: []filter.PathEntry{{Hash: hash, Interface: "eth0", Hops: &hopsVal}},
Announces: map[string]graph.Announce{
hash: {DestinationHash: hash, Aspect: aspect, DisplayName: name, LastSeen: "t"},
},
HopMax: &hopMax,
Search: search,
DarkMode: true,
LOD: "medium",
}
res := graph.BuildPathGraph(req)
if len(res.Nodes) != len(res.Edges) {
t.Fatalf("nodes/edges mismatch %d/%d", len(res.Nodes), len(res.Edges))
}
if len(res.Nodes) != len(res.ProcessedNodeIDs) {
t.Fatalf("node id mismatch")
}
})
}
func BenchmarkBuildPathGraph1000(b *testing.B) {
req := sampleRequest(1000)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
local := req
local.Positions = map[string]graph.XY{"eth0": {X: 100, Y: 200}}
_ = graph.BuildPathGraph(local)
}
}
func BenchmarkBuildPathGraph2000(b *testing.B) {
req := sampleRequest(2000)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
local := req
local.Positions = map[string]graph.XY{"eth0": {X: 100, Y: 200}}
_ = graph.BuildPathGraph(local)
}
}
func TestBuildPathGraphAllocBudget(t *testing.T) {
req := sampleRequest(200)
allocs := testing.AllocsPerRun(20, func() {
local := req
local.Positions = map[string]graph.XY{"eth0": {X: 100, Y: 200}}
_ = graph.BuildPathGraph(local)
})
// Graph build must allocate output slices and titles, but stay bounded.
const maxAllocs = 5000
if allocs > maxAllocs {
t.Fatalf("alloc budget exceeded: %v > %d", allocs, maxAllocs)
}
}

View file

@ -0,0 +1,369 @@
// SPDX-License-Identifier: 0BSD
package graph
import (
"math"
"strings"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/filter"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/hashpos"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/icon"
)
// InterfaceIn is a preformatted interface row from the JS side.
type InterfaceIn struct {
Name string `json:"name"`
Label string `json:"label"`
Title string `json:"title"`
Online bool `json:"online"`
}
// DiscoveredIn is a preformatted discovered-interface row.
type DiscoveredIn struct {
ID string `json:"id"`
Label string `json:"label"`
Title string `json:"title"`
Connected bool `json:"connected"`
Hops *float64 `json:"hops"`
}
// FullRequest builds the entire visualiser graph in one WASM pass.
type FullRequest struct {
MeLabel string `json:"me_label"`
MeTitle string `json:"me_title"`
MeImage string `json:"me_image"`
IdentityHash string `json:"identity_hash"`
Interfaces []InterfaceIn `json:"interfaces"`
PathOnlyInterfaces []InterfaceIn `json:"path_only_interfaces"`
Discovered []DiscoveredIn `json:"discovered"`
PathTable []filter.PathEntry `json:"path_table"`
Announces map[string]Announce `json:"announces"`
Conversations map[string]Conversation `json:"conversations"`
IconCache map[string]string `json:"icon_cache"`
Positions map[string]XY `json:"positions"`
HopMax *float64 `json:"hop_max"`
Search string `json:"search"`
DarkMode bool `json:"dark_mode"`
LOD string `json:"lod"`
Aspects []string `json:"aspects"`
QueueIcons bool `json:"queue_icons"`
IconGeneration int `json:"icon_generation"`
ShowDiscovered bool `json:"show_discovered"`
}
// FullResult is nodes/edges for the whole mesh plus layout seeds.
type FullResult struct {
Nodes []NodeOut `json:"nodes"`
Edges []EdgeOut `json:"edges"`
IconQueue []icon.QueueItem `json:"icon_queue"`
ProcessedNodeIDs []string `json:"processed_node_ids"`
ProcessedEdgeIDs []string `json:"processed_edge_ids"`
LayoutNodes []LayoutBody `json:"layout_nodes"`
LayoutEdges []LayoutSpring `json:"layout_edges"`
}
// LayoutBody is a compact body for the WASM force settle.
type LayoutBody struct {
ID string `json:"id"`
X float64 `json:"x"`
Y float64 `json:"y"`
Mass float64 `json:"mass"`
Fixed bool `json:"fixed"`
}
// LayoutSpring is a compact spring for the WASM force settle.
type LayoutSpring struct {
From string `json:"from"`
To string `json:"to"`
Length float64 `json:"length"`
}
// BuildFullGraph constructs me + interfaces + discovered + announce nodes/edges.
func BuildFullGraph(req FullRequest) FullResult {
searchLower := strings.ToLower(req.Search)
fontColor := "#000000"
if req.DarkMode {
fontColor = "#ffffff"
}
pos := req.Positions
if pos == nil {
pos = map[string]XY{}
}
est := len(req.PathTable) + len(req.Interfaces) + len(req.PathOnlyInterfaces) + len(req.Discovered) + 1
nodes := make([]NodeOut, 0, est)
edges := make([]EdgeOut, 0, est)
iconQueue := make([]icon.QueueItem, 0, 32)
nodeIDs := make([]string, 0, est)
edgeIDs := make([]string, 0, est)
layoutNodes := make([]LayoutBody, 0, est)
layoutEdges := make([]LayoutSpring, 0, est)
seen := make(map[string]struct{}, est)
addNode := func(n NodeOut, mass float64, fixed bool) {
if _, ok := seen[n.ID]; ok {
return
}
seen[n.ID] = struct{}{}
nodes = append(nodes, n)
nodeIDs = append(nodeIDs, n.ID)
layoutNodes = append(layoutNodes, LayoutBody{ID: n.ID, X: n.X, Y: n.Y, Mass: mass, Fixed: fixed})
}
addEdge := func(e EdgeOut, length float64) {
edges = append(edges, e)
edgeIDs = append(edgeIDs, e.ID)
layoutEdges = append(layoutEdges, LayoutSpring{From: e.From, To: e.To, Length: length})
}
meLabel := req.MeLabel
if meLabel == "" {
meLabel = "Local Node"
}
if filter.MatchesSearch(searchLower, meLabel) || filter.MatchesSearch(searchLower, req.IdentityHash) {
mp := resolveOr(pos, "me", 0, 0)
font := map[string]any{"color": fontColor, "size": 16.0, "bold": true}
me := NodeOut{
ID: "me",
Group: "me",
Size: 50,
OriginalSize: 50,
Shape: "circularImage",
OriginalShape: "circularImage",
Image: req.MeImage,
Label: meLabel,
Title: req.MeTitle,
Font: font,
Color: colorBlue(req.DarkMode),
X: mp.X,
Y: mp.Y,
}
applyLOD(&me, req.LOD, fontHighFor(req.DarkMode))
addNode(me, 4, true)
}
radius := 400.0
ifaceN := len(req.Interfaces)
for j, entry := range req.Interfaces {
if !filter.MatchesSearch(searchLower, entry.Label) && !filter.MatchesSearch(searchLower, entry.Name) {
continue
}
angle := 0.0
if ifaceN > 0 {
angle = (float64(j) / float64(ifaceN)) * 2 * math.Pi
}
init := XY{X: math.Cos(angle) * radius, Y: math.Sin(angle) * radius}
p := resolveOr(pos, entry.Name, init.X, init.Y)
node := NodeOut{
ID: entry.Name,
Group: "interface",
Label: entry.Label,
Title: entry.Title,
Size: 35,
OriginalSize: 35,
Shape: "circularImage",
OriginalShape: "circularImage",
Image: ifaceImage(entry.Online),
Color: ifaceColor(entry.Online, req.DarkMode),
Font: map[string]any{"color": fontColor, "size": 12.0, "bold": true},
X: p.X,
Y: p.Y,
}
applyLOD(&node, req.LOD, fontHighFor(req.DarkMode))
addNode(node, 2.5, false)
if _, ok := seen["me"]; ok {
eid := "me~" + entry.Name
col := edgeDirect(req.DarkMode)
if !entry.Online {
col = edgeOffline(req.DarkMode)
}
addEdge(EdgeOut{ID: eid, From: "me", To: entry.Name, Color: col, Width: 3, Hidden: false}, 200)
}
}
pathIfaceN := len(req.PathOnlyInterfaces)
for j, entry := range req.PathOnlyInterfaces {
if _, ok := seen[entry.Name]; ok {
continue
}
if !filter.MatchesSearch(searchLower, entry.Label) && !filter.MatchesSearch(searchLower, entry.Name) {
continue
}
angle := 0.0
if pathIfaceN > 0 {
angle = (float64(j) / float64(pathIfaceN)) * 2 * math.Pi
}
init := XY{X: math.Cos(angle) * radius, Y: math.Sin(angle) * radius}
p := resolveOr(pos, entry.Name, init.X, init.Y)
node := NodeOut{
ID: entry.Name,
Group: "interface",
Label: entry.Label,
Title: entry.Title,
Size: 35,
OriginalSize: 35,
Shape: "circularImage",
OriginalShape: "circularImage",
Image: "/assets/images/network-visualiser/interface_connected.png",
Color: ifaceColor(true, req.DarkMode),
Font: map[string]any{"color": fontColor, "size": 12.0, "bold": true},
X: p.X,
Y: p.Y,
}
applyLOD(&node, req.LOD, fontHighFor(req.DarkMode))
addNode(node, 2.5, false)
if _, ok := seen["me"]; ok {
eid := "me~" + entry.Name
addEdge(EdgeOut{ID: eid, From: "me", To: entry.Name, Color: edgeDirect(req.DarkMode), Width: 3, Hidden: false}, 200)
}
}
if req.ShowDiscovered {
for _, disc := range req.Discovered {
if req.HopMax != nil && disc.Hops != nil && *disc.Hops > *req.HopMax {
continue
}
if !filter.MatchesSearch(searchLower, disc.Label) {
continue
}
x, y := hashpos.XY(disc.ID, 800, 200)
p := resolveOr(pos, disc.ID, x, y)
node := NodeOut{
ID: disc.ID,
Group: "discovered",
Label: disc.Label,
Title: disc.Title,
Size: 25,
OriginalSize: 25,
Shape: "circularImage",
OriginalShape: "circularImage",
Image: ifaceImage(disc.Connected),
Color: discoveredColor(disc.Connected, req.DarkMode),
Font: map[string]any{"color": fontColor, "size": 10.0},
X: p.X,
Y: p.Y,
}
applyLOD(&node, req.LOD, fontHighFor(req.DarkMode))
addNode(node, 1.2, false)
if _, ok := seen["me"]; ok {
eid := "me~" + disc.ID
col := map[string]any{"color": "#06b6d4", "opacity": 0.35}
if req.DarkMode {
col["color"] = "#155e75"
}
addEdge(EdgeOut{ID: eid, From: "me", To: disc.ID, Color: col, Width: 1, Hidden: false}, 320)
}
}
}
pathRes := BuildPathGraph(Request{
PathTable: req.PathTable,
Announces: req.Announces,
Conversations: req.Conversations,
IconCache: req.IconCache,
Positions: pos,
HopMax: req.HopMax,
Search: req.Search,
DarkMode: req.DarkMode,
LOD: req.LOD,
Aspects: req.Aspects,
QueueIcons: req.QueueIcons,
IconGeneration: req.IconGeneration,
})
for _, n := range pathRes.Nodes {
addNode(n, 1, false)
}
for _, e := range pathRes.Edges {
length := 180.0
if e.Width >= 2 {
length = 150
}
addEdge(e, length)
}
iconQueue = append(iconQueue, pathRes.IconQueue...)
return FullResult{
Nodes: nodes,
Edges: edges,
IconQueue: iconQueue,
ProcessedNodeIDs: nodeIDs,
ProcessedEdgeIDs: edgeIDs,
LayoutNodes: layoutNodes,
LayoutEdges: layoutEdges,
}
}
func resolveOr(pos map[string]XY, id string, x, y float64) XY {
if prev, ok := pos[id]; ok {
return prev
}
p := XY{X: x, Y: y}
pos[id] = p
return p
}
func fontHighFor(dark bool) map[string]any {
if dark {
return fontHighLight
}
return fontHighDark
}
func colorBlue(dark bool) map[string]any {
if dark {
return colorLxmfMultiDark
}
return colorLxmfMultiLight
}
func ifaceImage(online bool) string {
if online {
return "/assets/images/network-visualiser/interface_connected.png"
}
return "/assets/images/network-visualiser/interface_disconnected.png"
}
func ifaceColor(online, dark bool) map[string]any {
if online {
bg := "#ecfdf5"
if dark {
bg = "#064e3b"
}
return nodeColor("#10b981", bg)
}
bg := "#fef2f2"
if dark {
bg = "#7f1d1d"
}
return nodeColor("#ef4444", bg)
}
func edgeDirect(dark bool) map[string]any {
if dark {
return edgeDirectDark
}
return edgeDirectLight
}
func edgeOffline(dark bool) map[string]any {
c := "#ef4444"
if dark {
c = "#f87171"
}
return map[string]any{"color": c, "opacity": 1.0}
}
func discoveredColor(connected, dark bool) map[string]any {
if connected {
bg := "#ecfeff"
if dark {
bg = "#164e63"
}
return nodeColor("#06b6d4", bg)
}
bg := "#f1f5f9"
if dark {
bg = "#1e293b"
}
return nodeColor("#64748b", bg)
}

View file

@ -0,0 +1,39 @@
// SPDX-License-Identifier: 0BSD
package graph_test
import (
"testing"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/filter"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/graph"
)
func TestBuildFullGraphIncludesMeAndInterfaces(t *testing.T) {
h := 1.0
res := graph.BuildFullGraph(graph.FullRequest{
MeLabel: "Home",
MeTitle: "Local",
MeImage: "/logo.png",
Interfaces: []graph.InterfaceIn{
{Name: "eth0", Label: "eth0", Title: "eth0 online", Online: true},
},
PathTable: []filter.PathEntry{
{Hash: "aa", Interface: "eth0", Hops: &h},
},
Announces: map[string]graph.Announce{
"aa": {DestinationHash: "aa", Aspect: "lxmf.delivery", DisplayName: "Alice", LastSeen: "now"},
},
DarkMode: true,
LOD: "high",
})
if len(res.Nodes) < 3 {
t.Fatalf("expected me+iface+announce, got %d", len(res.Nodes))
}
if len(res.LayoutNodes) != len(res.Nodes) {
t.Fatalf("layout bodies mismatch")
}
if len(res.LayoutEdges) != len(res.Edges) {
t.Fatalf("layout springs mismatch")
}
}

View file

@ -0,0 +1,66 @@
// SPDX-License-Identifier: 0BSD
// Package hashpos derives stable layout angles from node ids.
package hashpos
import "math"
// fnv1a32 hashes s with FNV-1a without heap allocation.
func fnv1a32(s string) uint32 {
const (
offset = 2166136261
prime = 16777619
)
h := uint32(offset)
for i := 0; i < len(s); i++ {
h ^= uint32(s[i])
h *= prime
}
return h
}
// fnv1a32Salt hashes id + NUL + salt with FNV-1a without heap allocation.
func fnv1a32Salt(id, salt string) uint32 {
const (
offset = 2166136261
prime = 16777619
)
h := uint32(offset)
for i := 0; i < len(id); i++ {
h ^= uint32(id[i])
h *= prime
}
h ^= 0
h *= prime
for i := 0; i < len(salt); i++ {
h ^= uint32(salt[i])
h *= prime
}
return h
}
// Angle01 returns a deterministic angle in [0, 2*Pi) for id.
func Angle01(id string) float64 {
u := fnv1a32(id)
return (float64(u%10000) / 10000.0) * 2 * math.Pi
}
// Dist01 returns a deterministic unit fraction in [0, 1) for id and salt.
func Dist01(id, salt string) float64 {
u := fnv1a32Salt(id, salt)
return float64(u%10000) / 10000.0
}
// XY places a point at radius base+span*Dist01 around the origin.
func XY(id string, base, span float64) (x, y float64) {
a := Angle01(id)
d := base + Dist01(id, "r")*span
return math.Cos(a) * d, math.Sin(a) * d
}
// Around places a point near parent using a deterministic offset.
func Around(id string, px, py, base, span float64) (x, y float64) {
a := Angle01(id)
d := base + Dist01(id, "r")*span
return px + math.Cos(a)*d, py + math.Sin(a)*d
}

View file

@ -0,0 +1,127 @@
// SPDX-License-Identifier: 0BSD
package hashpos_test
import (
"math"
"sync"
"testing"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/hashpos"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/leaktest"
)
func TestAngle01Stable(t *testing.T) {
a1 := hashpos.Angle01("abc")
a2 := hashpos.Angle01("abc")
if a1 != a2 {
t.Fatalf("unstable angle: %v vs %v", a1, a2)
}
if a1 < 0 || a1 >= 2*math.Pi {
t.Fatalf("angle out of range: %v", a1)
}
}
func TestDist01Range(t *testing.T) {
d := hashpos.Dist01("node", "r")
if d < 0 || d >= 1 {
t.Fatalf("dist out of range: %v", d)
}
}
func TestXYAroundFinite(t *testing.T) {
x, y := hashpos.XY("n1", 600, 200)
if math.IsNaN(x) || math.IsNaN(y) || math.IsInf(x, 0) || math.IsInf(y, 0) {
t.Fatalf("bad XY: %v %v", x, y)
}
ax, ay := hashpos.Around("n1", 10, 20, 150, 150)
if math.IsNaN(ax) || math.IsNaN(ay) {
t.Fatalf("bad Around: %v %v", ax, ay)
}
}
func TestHashposNoGoroutineLeak(t *testing.T) {
defer leaktest.Check(t)()
for i := 0; i < 1000; i++ {
_, _ = hashpos.XY("node", 100, 50)
_, _ = hashpos.Around("node", 1, 2, 10, 10)
}
}
func TestHashposRace(t *testing.T) {
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
id := "race-" + string(rune('a'+n%26))
for j := 0; j < 200; j++ {
_ = hashpos.Angle01(id)
_ = hashpos.Dist01(id, "r")
_, _ = hashpos.XY(id, 600, 200)
_, _ = hashpos.Around(id, 0, 0, 150, 150)
}
}(i)
}
wg.Wait()
}
func FuzzAngle01(f *testing.F) {
f.Add("")
f.Add("me")
f.Add("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
f.Fuzz(func(t *testing.T, id string) {
a := hashpos.Angle01(id)
if math.IsNaN(a) || math.IsInf(a, 0) {
t.Fatalf("bad angle %v for %q", a, id)
}
d := hashpos.Dist01(id, "r")
if d < 0 || d >= 1 || math.IsNaN(d) {
t.Fatalf("bad dist %v for %q", d, id)
}
x, y := hashpos.XY(id, 1, 1)
if math.IsNaN(x) || math.IsNaN(y) {
t.Fatalf("bad xy")
}
})
}
func BenchmarkAngle01(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = hashpos.Angle01("destinationhash0123456789abcdef")
}
}
func BenchmarkDist01(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = hashpos.Dist01("destinationhash0123456789abcdef", "r")
}
}
func BenchmarkXY(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = hashpos.XY("destinationhash0123456789abcdef", 600, 200)
}
}
func BenchmarkAround(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = hashpos.Around("destinationhash0123456789abcdef", 100, 200, 150, 150)
}
}
func TestHashposZeroAllocs(t *testing.T) {
allocs := testing.AllocsPerRun(1000, func() {
_ = hashpos.Angle01("destinationhash0123456789abcdef")
_ = hashpos.Dist01("destinationhash0123456789abcdef", "r")
_, _ = hashpos.XY("destinationhash0123456789abcdef", 600, 200)
_, _ = hashpos.Around("destinationhash0123456789abcdef", 0, 0, 150, 150)
})
if allocs != 0 {
t.Fatalf("expected 0 allocs, got %v", allocs)
}
}

View file

@ -0,0 +1,70 @@
// SPDX-License-Identifier: 0BSD
// Package icon collapses deferred icon paint work for the visualiser.
package icon
// QueueItem is one deferred custom-icon paint request.
type QueueItem struct {
NodeID string `json:"nodeId"`
CacheKey string `json:"cacheKey"`
IconName string `json:"iconName"`
FG string `json:"fg"`
BG string `json:"bg"`
Size float64 `json:"size"`
Generation int `json:"generation"`
}
// DedupedBucket is one unique cache key with all node ids that share it.
type DedupedBucket struct {
CacheKey string `json:"cacheKey"`
NodeIDs []string `json:"nodeIds"`
IconName string `json:"iconName"`
FG string `json:"fg"`
BG string `json:"bg"`
Size float64 `json:"size"`
Generation int `json:"generation"`
}
// DedupeQueueEntries collapses duplicate cacheKey entries into one paint job.
func DedupeQueueEntries(queue []QueueItem) []DedupedBucket {
if len(queue) == 0 {
return nil
}
order := make([]string, 0, 16)
byKey := make(map[string]*DedupedBucket, 16)
seenNodes := make(map[string]map[string]struct{}, 16)
for i := range queue {
item := &queue[i]
if item.CacheKey == "" || item.NodeID == "" {
continue
}
bucket, ok := byKey[item.CacheKey]
if !ok {
bucket = &DedupedBucket{
CacheKey: item.CacheKey,
NodeIDs: make([]string, 0, 4),
IconName: item.IconName,
FG: item.FG,
BG: item.BG,
Size: item.Size,
Generation: item.Generation,
}
byKey[item.CacheKey] = bucket
seenNodes[item.CacheKey] = make(map[string]struct{}, 4)
order = append(order, item.CacheKey)
}
seen := seenNodes[item.CacheKey]
if _, dup := seen[item.NodeID]; dup {
continue
}
seen[item.NodeID] = struct{}{}
bucket.NodeIDs = append(bucket.NodeIDs, item.NodeID)
}
out := make([]DedupedBucket, 0, len(order))
for _, key := range order {
out = append(out, *byKey[key])
}
return out
}

View file

@ -0,0 +1,114 @@
// SPDX-License-Identifier: 0BSD
package icon_test
import (
"sync"
"testing"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/icon"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/leaktest"
)
func TestDedupeQueueEntries(t *testing.T) {
queue := []icon.QueueItem{
{NodeID: "n1", CacheKey: "k1", IconName: "a", FG: "#000", BG: "#fff", Size: 64, Generation: 1},
{NodeID: "n2", CacheKey: "k1", IconName: "a", FG: "#000", BG: "#fff", Size: 64, Generation: 1},
{NodeID: "n3", CacheKey: "k2", IconName: "b", FG: "#111", BG: "#eee", Size: 64, Generation: 1},
{NodeID: "n1", CacheKey: "k1", IconName: "a", FG: "#000", BG: "#fff", Size: 64, Generation: 1},
}
out := icon.DedupeQueueEntries(queue)
if len(out) != 2 {
t.Fatalf("expected 2 buckets, got %d", len(out))
}
if len(out[0].NodeIDs) != 2 || out[0].NodeIDs[0] != "n1" || out[0].NodeIDs[1] != "n2" {
t.Fatalf("unexpected k1 nodes: %#v", out[0].NodeIDs)
}
if icon.DedupeQueueEntries(nil) == nil {
// nil or empty both acceptable if empty result length is 0
}
if len(icon.DedupeQueueEntries(nil)) != 0 {
t.Fatal("empty queue should yield empty result")
}
}
func TestIconNoGoroutineLeak(t *testing.T) {
defer leaktest.Check(t)()
queue := make([]icon.QueueItem, 200)
for i := range queue {
queue[i] = icon.QueueItem{
NodeID: "n" + string(rune('0'+i%10)),
CacheKey: "k" + string(rune('a'+i%5)),
IconName: "ico",
FG: "#000",
BG: "#fff",
Size: 64,
}
}
for i := 0; i < 100; i++ {
_ = icon.DedupeQueueEntries(queue)
}
}
func TestIconRace(t *testing.T) {
queue := make([]icon.QueueItem, 300)
for i := range queue {
queue[i] = icon.QueueItem{
NodeID: "n" + string(rune('0'+i%10)),
CacheKey: "k" + string(rune('a'+i%8)),
IconName: "ico",
Size: 64,
}
}
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 50; j++ {
_ = icon.DedupeQueueEntries(queue)
}
}()
}
wg.Wait()
}
func FuzzDedupeQueueEntries(f *testing.F) {
f.Add("n1", "k1", "icon", "#000", "#fff")
f.Add("", "k", "i", "", "")
f.Fuzz(func(t *testing.T, nodeID, cacheKey, name, fg, bg string) {
queue := []icon.QueueItem{
{NodeID: nodeID, CacheKey: cacheKey, IconName: name, FG: fg, BG: bg, Size: 64},
{NodeID: nodeID, CacheKey: cacheKey, IconName: name, FG: fg, BG: bg, Size: 64},
}
out := icon.DedupeQueueEntries(queue)
if cacheKey == "" || nodeID == "" {
if len(out) != 0 {
t.Fatalf("expected skip, got %#v", out)
}
return
}
if len(out) != 1 || len(out[0].NodeIDs) != 1 {
t.Fatalf("unexpected %#v", out)
}
})
}
func BenchmarkDedupeQueueEntries(b *testing.B) {
queue := make([]icon.QueueItem, 1000)
for i := range queue {
queue[i] = icon.QueueItem{
NodeID: "n" + string(rune('0'+i%20)),
CacheKey: "k" + string(rune('a'+i%10)),
IconName: "ico",
FG: "#000",
BG: "#fff",
Size: 64,
}
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = icon.DedupeQueueEntries(queue)
}
}

View file

@ -0,0 +1,247 @@
// SPDX-License-Identifier: 0BSD
// Package layout settles node positions with a fast force simulation.
// Runs in WASM so vis-network can keep its JS physics solver off
// (that solver is the main FPS bottleneck for large graphs).
package layout
import (
"math"
)
// Node is one body in the layout simulation.
type Node struct {
ID string `json:"id"`
X float64 `json:"x"`
Y float64 `json:"y"`
Mass float64 `json:"mass"`
Fixed bool `json:"fixed"`
}
// Edge is a spring between two node ids.
type Edge struct {
From string `json:"from"`
To string `json:"to"`
Length float64 `json:"length"`
}
// Request configures a layout settle pass.
type Request struct {
Nodes []Node `json:"nodes"`
Edges []Edge `json:"edges"`
Iterations int `json:"iterations"`
Gravity float64 `json:"gravity"`
Repulsion float64 `json:"repulsion"`
SpringK float64 `json:"spring_k"`
Damping float64 `json:"damping"`
MaxSpeed float64 `json:"max_speed"`
}
// Result is settled positions keyed by node id.
type Result struct {
Positions map[string]XY `json:"positions"`
Iterations int `json:"iterations"`
}
// XY is a 2D point.
type XY struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
type body struct {
id string
x, y float64
vx, vy float64
mass float64
fixed bool
}
// Settle runs a damped spring + grid-repulsion layout in-place.
func Settle(req Request) Result {
n := len(req.Nodes)
out := Result{Positions: make(map[string]XY, n)}
if n == 0 {
return out
}
iters := req.Iterations
if iters <= 0 {
iters = pickIterations(n)
}
if iters > 400 {
iters = 400
}
gravity := req.Gravity
if gravity == 0 {
gravity = 0.012
}
repulsion := req.Repulsion
if repulsion == 0 {
repulsion = 1800
}
springK := req.SpringK
if springK == 0 {
springK = 0.045
}
damping := req.Damping
if damping == 0 {
damping = 0.82
}
maxSpeed := req.MaxSpeed
if maxSpeed == 0 {
maxSpeed = 40
}
bodies := make([]body, n)
index := make(map[string]int, n)
for i := range req.Nodes {
nd := &req.Nodes[i]
mass := nd.Mass
if mass <= 0 {
mass = 1
}
bodies[i] = body{
id: nd.ID,
x: nd.X,
y: nd.Y,
mass: mass,
fixed: nd.Fixed || nd.ID == "me",
}
index[nd.ID] = i
}
type spring struct {
a, b int
len float64
}
springs := make([]spring, 0, len(req.Edges))
for i := range req.Edges {
e := &req.Edges[i]
ai, okA := index[e.From]
bi, okB := index[e.To]
if !okA || !okB || ai == bi {
continue
}
length := e.Length
if length <= 0 {
length = 180
}
springs = append(springs, spring{a: ai, b: bi, len: length})
}
cellSize := 160.0
for step := 0; step < iters; step++ {
fx := make([]float64, n)
fy := make([]float64, n)
// Weak pull toward origin keeps the mesh centred.
for i := range bodies {
if bodies[i].fixed {
continue
}
fx[i] -= bodies[i].x * gravity * bodies[i].mass
fy[i] -= bodies[i].y * gravity * bodies[i].mass
}
// Grid-bucketed repulsion (near O(n) average).
buckets := make(map[[2]int][]int, n/2+1)
for i := range bodies {
cx := int(math.Floor(bodies[i].x / cellSize))
cy := int(math.Floor(bodies[i].y / cellSize))
key := [2]int{cx, cy}
buckets[key] = append(buckets[key], i)
}
for i := range bodies {
if bodies[i].fixed {
continue
}
cx := int(math.Floor(bodies[i].x / cellSize))
cy := int(math.Floor(bodies[i].y / cellSize))
for dx := -1; dx <= 1; dx++ {
for dy := -1; dy <= 1; dy++ {
list := buckets[[2]int{cx + dx, cy + dy}]
for _, j := range list {
if j <= i {
continue
}
dxp := bodies[i].x - bodies[j].x
dyp := bodies[i].y - bodies[j].y
dist2 := dxp*dxp + dyp*dyp + 0.01
inv := 1.0 / math.Sqrt(dist2)
force := repulsion * bodies[i].mass * bodies[j].mass * inv * inv
fx[i] += dxp * inv * force
fy[i] += dyp * inv * force
if !bodies[j].fixed {
fx[j] -= dxp * inv * force
fy[j] -= dyp * inv * force
}
}
}
}
}
// Springs
for _, s := range springs {
a := &bodies[s.a]
b := &bodies[s.b]
dxp := b.x - a.x
dyp := b.y - a.y
dist := math.Sqrt(dxp*dxp + dyp*dyp)
if dist < 0.01 {
dist = 0.01
}
delta := dist - s.len
force := springK * delta
ux := dxp / dist
uy := dyp / dist
if !a.fixed {
fx[s.a] += ux * force
fy[s.a] += uy * force
}
if !b.fixed {
fx[s.b] -= ux * force
fy[s.b] -= uy * force
}
}
// Integrate
for i := range bodies {
if bodies[i].fixed {
bodies[i].vx = 0
bodies[i].vy = 0
continue
}
bodies[i].vx = (bodies[i].vx + fx[i]/bodies[i].mass) * damping
bodies[i].vy = (bodies[i].vy + fy[i]/bodies[i].mass) * damping
speed := math.Hypot(bodies[i].vx, bodies[i].vy)
if speed > maxSpeed {
scale := maxSpeed / speed
bodies[i].vx *= scale
bodies[i].vy *= scale
}
bodies[i].x += bodies[i].vx
bodies[i].y += bodies[i].vy
}
}
for i := range bodies {
out.Positions[bodies[i].id] = XY{X: bodies[i].x, Y: bodies[i].y}
}
out.Iterations = iters
return out
}
func pickIterations(n int) int {
if n >= 1500 {
return 45
}
if n >= 600 {
return 70
}
if n >= 200 {
return 100
}
return 140
}

View file

@ -0,0 +1,79 @@
// SPDX-License-Identifier: 0BSD
package layout_test
import (
"testing"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/layout"
)
func TestSettleMovesUnfixedNodes(t *testing.T) {
res := layout.Settle(layout.Request{
Nodes: []layout.Node{
{ID: "me", X: 0, Y: 0, Mass: 4, Fixed: true},
{ID: "a", X: 10, Y: 0, Mass: 1},
{ID: "b", X: -10, Y: 0, Mass: 1},
},
Edges: []layout.Edge{
{From: "me", To: "a", Length: 200},
{From: "me", To: "b", Length: 200},
},
Iterations: 80,
})
if len(res.Positions) != 3 {
t.Fatalf("expected 3 positions, got %d", len(res.Positions))
}
if res.Positions["me"].X != 0 || res.Positions["me"].Y != 0 {
t.Fatalf("me should stay fixed: %#v", res.Positions["me"])
}
// Springs should push a/b outward from the tiny start distance.
if abs(res.Positions["a"].X) < 20 {
t.Fatalf("expected a to move outward, got %#v", res.Positions["a"])
}
}
func TestSettleEmpty(t *testing.T) {
res := layout.Settle(layout.Request{})
if len(res.Positions) != 0 {
t.Fatal("expected empty")
}
}
func BenchmarkSettle500(b *testing.B) {
nodes := make([]layout.Node, 500)
edges := make([]layout.Edge, 0, 500)
nodes[0] = layout.Node{ID: "me", Fixed: true, Mass: 4}
for i := 1; i < 500; i++ {
id := "n" + itoa(i)
nodes[i] = layout.Node{ID: id, X: float64(i%50) * 10, Y: float64(i/50) * 10, Mass: 1}
edges = append(edges, layout.Edge{From: "me", To: id, Length: 200})
}
req := layout.Request{Nodes: nodes, Edges: edges, Iterations: 60}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = layout.Settle(req)
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [16]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
func abs(v float64) float64 {
if v < 0 {
return -v
}
return v
}

View file

@ -0,0 +1,34 @@
// SPDX-License-Identifier: 0BSD
// Package leaktest provides lightweight goroutine leak checks without deps.
package leaktest
import (
"runtime"
"testing"
"time"
)
// Check returns a cleanup that fails the test if goroutines leaked.
func Check(t testing.TB) func() {
t.Helper()
runtime.GC()
time.Sleep(5 * time.Millisecond)
before := runtime.NumGoroutine()
return func() {
t.Helper()
deadline := time.Now().Add(500 * time.Millisecond)
var after int
for {
runtime.GC()
after = runtime.NumGoroutine()
if after <= before {
return
}
if time.Now().After(deadline) {
t.Fatalf("goroutine leak: before=%d after=%d", before, after)
}
time.Sleep(10 * time.Millisecond)
}
}
}

View file

@ -0,0 +1,189 @@
// SPDX-License-Identifier: 0BSD
// Package lod computes level-of-detail property patches for vis-network nodes.
package lod
// NodeIn is the minimal node state needed to compute an LOD patch.
type NodeIn struct {
ID string `json:"id"`
Group string `json:"group"`
Shape string `json:"shape"`
Size float64 `json:"size"`
OriginalShape string `json:"_originalShape"`
OriginalSize float64 `json:"_originalSize"`
Color map[string]any `json:"color"`
Font *FontIn `json:"font"`
}
// FontIn carries the current font size when present.
type FontIn struct {
Size *float64 `json:"size"`
}
// Update is a sparse vis-network node patch for one LOD change.
type Update struct {
ID string `json:"id"`
Shape string `json:"shape,omitempty"`
Size *float64 `json:"size,omitempty"`
Font map[string]any `json:"font,omitempty"`
Color map[string]any `json:"color,omitempty"`
}
var (
fontSize0 = map[string]any{"size": 0.0}
fontHighLight11 = map[string]any{"size": 11.0, "color": "#ffffff"}
fontHighDark11 = map[string]any{"size": 11.0, "color": "#000000"}
fontHighLight16 = map[string]any{"size": 16.0, "color": "#ffffff"}
fontHighDark16 = map[string]any{"size": 16.0, "color": "#000000"}
colorBlueLight = nodeColor("#3b82f6", "#eff6ff")
colorBlueDark = nodeColor("#3b82f6", "#1e40af")
size10 = 10.0
size15 = 15.0
size25 = 25.0
size50 = 50.0
)
// LevelFromScale maps a vis-network camera scale to low/medium/high.
func LevelFromScale(scale float64) string {
if scale < 0.2 {
return "low"
}
if scale < 0.5 {
return "medium"
}
return "high"
}
// ComputeUpdates returns only nodes whose LOD props actually change.
func ComputeUpdates(nodes []NodeIn, level string, darkMode bool) []Update {
if len(nodes) == 0 {
return nil
}
blue := colorBlueLight
if darkMode {
blue = colorBlueDark
}
fontMe := fontHighDark16
fontPeer := fontHighDark11
if darkMode {
fontMe = fontHighLight16
fontPeer = fontHighLight11
}
out := make([]Update, 0, len(nodes)/4+1)
for i := range nodes {
n := &nodes[i]
next := propsFor(n, level, fontMe, fontPeer, blue)
if !changed(n, next) {
continue
}
out = append(out, next)
}
return out
}
func propsFor(n *NodeIn, level string, fontMe, fontPeer, blue map[string]any) Update {
u := Update{ID: n.ID}
switch level {
case "low":
if n.ID == "me" {
u.Size = &size15
} else {
u.Size = &size10
}
u.Shape = "dot"
u.Font = fontSize0
if n.Group == "interface" && n.Color != nil {
u.Color = n.Color
} else {
u.Color = blue
}
case "medium":
shape := n.OriginalShape
if shape == "" {
shape = "circularImage"
}
u.Shape = shape
u.Size = originalSizePtr(n)
u.Font = fontSize0
default:
shape := n.OriginalShape
if shape == "" {
shape = "circularImage"
}
u.Shape = shape
u.Size = originalSizePtr(n)
if n.ID == "me" {
u.Font = fontMe
} else {
u.Font = fontPeer
}
}
return u
}
func originalSizePtr(n *NodeIn) *float64 {
if n.OriginalSize != 0 {
// Return a pointer into a stable set of common sizes when possible.
switch n.OriginalSize {
case 10:
return &size10
case 15:
return &size15
case 25:
return &size25
case 50:
return &size50
}
v := n.OriginalSize
return &v
}
if n.ID == "me" {
return &size50
}
return &size25
}
func changed(n *NodeIn, next Update) bool {
if next.Shape != "" && next.Shape != n.Shape {
return true
}
if next.Size != nil && *next.Size != n.Size {
return true
}
if next.Font != nil {
ns, ok := next.Font["size"]
if ok {
var cur float64
if n.Font != nil && n.Font.Size != nil {
cur = *n.Font.Size
}
switch v := ns.(type) {
case float64:
if v != cur {
return true
}
case int:
if float64(v) != cur {
return true
}
}
}
}
return false
}
func nodeColor(border, background string) map[string]any {
return map[string]any{
"border": border,
"background": background,
"highlight": map[string]any{
"border": border,
"background": background,
},
"hover": map[string]any{
"border": border,
"background": background,
},
}
}

View file

@ -0,0 +1,157 @@
// SPDX-License-Identifier: 0BSD
package lod_test
import (
"sync"
"testing"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/leaktest"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/lod"
)
func TestLevelFromScale(t *testing.T) {
if lod.LevelFromScale(0.1) != "low" {
t.Fatal("expected low")
}
if lod.LevelFromScale(0.3) != "medium" {
t.Fatal("expected medium")
}
if lod.LevelFromScale(0.8) != "high" {
t.Fatal("expected high")
}
}
func TestComputeUpdatesLow(t *testing.T) {
orig := 25.0
nodes := []lod.NodeIn{
{ID: "n1", Shape: "circularImage", Size: 25, OriginalShape: "circularImage", OriginalSize: orig},
}
out := lod.ComputeUpdates(nodes, "low", false)
if len(out) != 1 || out[0].Shape != "dot" {
t.Fatalf("unexpected: %#v", out)
}
if out[0].Size == nil || *out[0].Size != 10 {
t.Fatalf("expected size 10, got %#v", out[0].Size)
}
}
func TestComputeUpdatesNoChange(t *testing.T) {
sz := 10.0
fs := 0.0
nodes := []lod.NodeIn{
{ID: "n1", Shape: "dot", Size: 10, OriginalShape: "circularImage", OriginalSize: 25, Font: &lod.FontIn{Size: &fs}},
}
out := lod.ComputeUpdates(nodes, "low", false)
_ = sz
if len(out) != 0 {
t.Fatalf("expected no updates, got %#v", out)
}
}
func TestLODNoGoroutineLeak(t *testing.T) {
defer leaktest.Check(t)()
nodes := make([]lod.NodeIn, 500)
for i := range nodes {
nodes[i] = lod.NodeIn{
ID: "n" + string(rune('a'+i%26)),
Shape: "circularImage",
Size: 25,
OriginalShape: "circularImage",
OriginalSize: 25,
}
}
for i := 0; i < 100; i++ {
_ = lod.ComputeUpdates(nodes, "low", true)
_ = lod.LevelFromScale(0.25)
}
}
func TestLODRace(t *testing.T) {
nodes := make([]lod.NodeIn, 1000)
for i := range nodes {
nodes[i] = lod.NodeIn{
ID: "n" + string(rune('0'+i%10)),
Shape: "circularImage",
Size: 25,
OriginalShape: "circularImage",
OriginalSize: 25,
}
}
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func(level string) {
defer wg.Done()
for j := 0; j < 50; j++ {
_ = lod.ComputeUpdates(nodes, level, j%2 == 0)
_ = lod.LevelFromScale(float64(j) / 100)
}
}([]string{"low", "medium", "high"}[i%3])
}
wg.Wait()
}
func FuzzLevelFromScale(f *testing.F) {
f.Add(0.0)
f.Add(0.2)
f.Add(0.5)
f.Add(1.0)
f.Fuzz(func(t *testing.T, scale float64) {
level := lod.LevelFromScale(scale)
switch level {
case "low", "medium", "high":
default:
t.Fatalf("bad level %q", level)
}
})
}
func FuzzComputeUpdates(f *testing.F) {
f.Add("n1", "circularImage", 25.0, "low", true)
f.Add("me", "dot", 15.0, "high", false)
f.Fuzz(func(t *testing.T, id, shape string, size float64, level string, dark bool) {
nodes := []lod.NodeIn{{
ID: id,
Shape: shape,
Size: size,
OriginalShape: "circularImage",
OriginalSize: 25,
}}
_ = lod.ComputeUpdates(nodes, level, dark)
})
}
func BenchmarkComputeUpdatesLow(b *testing.B) {
nodes := make([]lod.NodeIn, 2000)
for i := range nodes {
nodes[i] = lod.NodeIn{
ID: "n" + string(rune('a'+i%26)),
Shape: "circularImage",
Size: 25,
OriginalShape: "circularImage",
OriginalSize: 25,
}
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = lod.ComputeUpdates(nodes, "low", false)
}
}
func BenchmarkLevelFromScale(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = lod.LevelFromScale(0.33)
}
}
func TestLevelFromScaleZeroAllocs(t *testing.T) {
allocs := testing.AllocsPerRun(1000, func() {
_ = lod.LevelFromScale(0.33)
})
if allocs != 0 {
t.Fatalf("expected 0 allocs, got %v", allocs)
}
}

View file

@ -42,9 +42,42 @@ const backendUsesHttps = !envBool(process.env.MESHCHAT_NO_HTTPS);
const e2eBackendOrigin = backendUsesHttps
? `https://127.0.0.1:${e2eBackendPort}`
: `http://127.0.0.1:${e2eBackendPort}`;
const e2eBackendWs = backendUsesHttps ? `wss://127.0.0.1:${e2eBackendPort}` : `ws://127.0.0.1:${e2eBackendPort}`;
// http-proxy expects an http(s) target for WS upgrades (ws: true). Using wss://
// here has caused noisy write EPIPE / reconnect loops under Vite 8.
const backendProxyTls = backendUsesHttps ? { secure: false } : {};
/**
* Attach quiet handlers for expected proxy disconnects (browser refresh,
* client reconnect, peer closed before proxy flush).
* @param {import('http-proxy').Server} proxy
*/
function configureQuietProxyErrors(proxy) {
proxy.on("error", (err, _req, res) => {
const code = err && err.code;
if (code === "EPIPE" || code === "ECONNRESET" || code === "ECONNREFUSED") {
if (res && !res.headersSent && typeof res.writeHead === "function") {
try {
res.writeHead(502);
res.end("Bad gateway");
} catch {
/* already closed */
}
}
return;
}
console.error("[vite] proxy error:", err);
});
proxy.on("proxyReqWs", (_proxyReq, _req, socket) => {
socket.on("error", (err) => {
const code = err && err.code;
if (code === "EPIPE" || code === "ECONNRESET") {
return;
}
console.error("[vite] ws proxy socket error:", err);
});
});
}
const appBuildTimeIso = new Date().toISOString();
function isMicronWasmBundledResolved() {
@ -61,7 +94,22 @@ function isMicronWasmBundledResolved() {
}
}
function isVisualiserWasmBundledResolved() {
const wasmDir = path.join(__dirname, "meshchatx", "src", "frontend", "public", "vendor", "visualiser-wasm");
const wasmFile = path.join(wasmDir, "visualiser.wasm");
const execFile = path.join(wasmDir, "wasm_exec.js");
try {
if (!fs.existsSync(wasmFile) || !fs.existsSync(execFile)) {
return false;
}
return fs.statSync(wasmFile).size >= 8192 && fs.statSync(execFile).size >= 1024;
} catch {
return false;
}
}
const micronWasmBundled = isMicronWasmBundledResolved();
const visualiserWasmBundled = isVisualiserWasmBundledResolved();
function loadMicronWasmIntegrity() {
if (!micronWasmBundled) return null;
@ -84,15 +132,39 @@ function loadMicronWasmIntegrity() {
}
}
function loadVisualiserWasmIntegrity() {
if (!visualiserWasmBundled) return null;
const integrityPath = path.join(
__dirname,
"meshchatx",
"src",
"frontend",
"public",
"vendor",
"visualiser-wasm",
"integrity.json"
);
try {
return JSON.parse(fs.readFileSync(integrityPath, "utf-8"));
} catch {
console.warn("vite: could not load visualiser-wasm integrity.json");
return null;
}
}
const micronWasmIntegrity = loadMicronWasmIntegrity();
const visualiserWasmIntegrity = loadVisualiserWasmIntegrity();
export default defineConfig({
define: {
__APP_BUILD_TIME__: JSON.stringify(appBuildTimeIso),
"import.meta.env.VITE_MICRON_WASM_BUNDLED": JSON.stringify(micronWasmBundled ? "true" : "false"),
"import.meta.env.VITE_MICRON_PARSER_GO_RELEASE": JSON.stringify(MICRON_PARSER_GO_RELEASE_TAG),
"import.meta.env.VITE_VISUALISER_WASM_BUNDLED": JSON.stringify(visualiserWasmBundled ? "true" : "false"),
__MICRON_WASM_SRI_WASM__: JSON.stringify(micronWasmIntegrity?.wasm || ""),
__MICRON_WASM_SRI_EXEC__: JSON.stringify(micronWasmIntegrity?.wasmExec || ""),
__VISUALISER_WASM_SRI_WASM__: JSON.stringify(visualiserWasmIntegrity?.wasm || ""),
__VISUALISER_WASM_SRI_EXEC__: JSON.stringify(visualiserWasmIntegrity?.wasmExec || ""),
},
plugins: [
tailwindcss(),
@ -109,11 +181,39 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
"/api": { target: e2eBackendOrigin, changeOrigin: true, ...backendProxyTls },
"/ws": { target: e2eBackendWs, ws: true, ...backendProxyTls },
"/ws/telephone/audio": { target: e2eBackendWs, ws: true, ...backendProxyTls },
"/reticulum-docs": { target: e2eBackendOrigin, changeOrigin: true, ...backendProxyTls },
"/meshchatx-docs": { target: e2eBackendOrigin, changeOrigin: true, ...backendProxyTls },
"/api": {
target: e2eBackendOrigin,
changeOrigin: true,
configure: configureQuietProxyErrors,
...backendProxyTls,
},
// More specific WS path before the /ws prefix match.
"/ws/telephone/audio": {
target: e2eBackendOrigin,
ws: true,
changeOrigin: true,
configure: configureQuietProxyErrors,
...backendProxyTls,
},
"/ws": {
target: e2eBackendOrigin,
ws: true,
changeOrigin: true,
configure: configureQuietProxyErrors,
...backendProxyTls,
},
"/reticulum-docs": {
target: e2eBackendOrigin,
changeOrigin: true,
configure: configureQuietProxyErrors,
...backendProxyTls,
},
"/meshchatx-docs": {
target: e2eBackendOrigin,
changeOrigin: true,
configure: configureQuietProxyErrors,
...backendProxyTls,
},
},
},