mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: integrate visualiser-wasm build and testing into the project, improve network visualization capabilities and add battery saver
This commit is contained in:
parent
d801d095de
commit
fb08e76be2
65 changed files with 6930 additions and 694 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
59
Taskfile.yml
59
Taskfile.yml
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -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,
|
||||
|
|
|
|||
94
meshchatx/src/backend/process_resource_breakdown.py
Normal file
94
meshchatx/src/backend/process_resource_breakdown.py
Normal 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))
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
206
meshchatx/src/frontend/js/VisualiserWasmLoader.js
Normal file
206
meshchatx/src/frontend/js/VisualiserWasmLoader.js
Normal 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;
|
||||
}
|
||||
}
|
||||
158
meshchatx/src/frontend/js/networkVisualiserCache.js
Normal file
158
meshchatx/src/frontend/js/networkVisualiserCache.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
55
meshchatx/src/frontend/js/resourceBreakdown.js
Normal file
55
meshchatx/src/frontend/js/resourceBreakdown.js
Normal 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));
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
239
meshchatx/src/frontend/js/settings/batterySaverPrefs.js
Normal file
239
meshchatx/src/frontend/js/settings/batterySaverPrefs.js
Normal 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 };
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "2–10. 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",
|
||||
|
|
|
|||
|
|
@ -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": "2–10. 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",
|
||||
|
|
|
|||
|
|
@ -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": "2–10. 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",
|
||||
|
|
|
|||
|
|
@ -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": "2–10. 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",
|
||||
|
|
|
|||
|
|
@ -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": "2–10. 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",
|
||||
|
|
|
|||
|
|
@ -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": "2–10. 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",
|
||||
|
|
|
|||
|
|
@ -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": "2–10. 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",
|
||||
|
|
|
|||
|
|
@ -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": "2–10. 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": "Забаненные",
|
||||
|
|
|
|||
|
|
@ -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": "2–10. 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": "放逐",
|
||||
|
|
|
|||
6
meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json
vendored
Normal file
6
meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json
vendored
Normal 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"
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
106
scripts/build-visualiser-wasm.mjs
Normal file
106
scripts/build-visualiser-wasm.mjs
Normal 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();
|
||||
51
tests/backend/test_process_resource_breakdown.py
Normal file
51
tests/backend/test_process_resource_breakdown.py
Normal 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
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
56
tests/frontend/NetworkVisualiserToolbar.test.js
Normal file
56
tests/frontend/NetworkVisualiserToolbar.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
57
tests/frontend/VisualiserWasmLoader.test.js
Normal file
57
tests/frontend/VisualiserWasmLoader.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
59
tests/frontend/batterySaverBitrateApply.test.js
Normal file
59
tests/frontend/batterySaverBitrateApply.test.js
Normal 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"]);
|
||||
});
|
||||
});
|
||||
112
tests/frontend/batterySaverPrefs.test.js
Normal file
112
tests/frontend/batterySaverPrefs.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
129
tests/frontend/networkVisualiserCache.test.js
Normal file
129
tests/frontend/networkVisualiserCache.test.js
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
24
tests/frontend/resourceBreakdown.test.js
Normal file
24
tests/frontend/resourceBreakdown.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
45
tests/frontend/settingsVisualiserPrefs.test.js
Normal file
45
tests/frontend/settingsVisualiserPrefs.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
140
visualiser-wasm/cmd/wasm/main.go
Normal file
140
visualiser-wasm/cmd/wasm/main.go
Normal 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
5
visualiser-wasm/go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
module github.com/Quad4-Software/MeshChatX/visualiser-wasm
|
||||
|
||||
go 1.22
|
||||
90
visualiser-wasm/internal/filter/hop.go
Normal file
90
visualiser-wasm/internal/filter/hop.go
Normal 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
|
||||
}
|
||||
148
visualiser-wasm/internal/filter/hop_test.go
Normal file
148
visualiser-wasm/internal/filter/hop_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
407
visualiser-wasm/internal/graph/build.go
Normal file
407
visualiser-wasm/internal/graph/build.go
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
196
visualiser-wasm/internal/graph/build_test.go
Normal file
196
visualiser-wasm/internal/graph/build_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
369
visualiser-wasm/internal/graph/full.go
Normal file
369
visualiser-wasm/internal/graph/full.go
Normal 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)
|
||||
}
|
||||
39
visualiser-wasm/internal/graph/full_test.go
Normal file
39
visualiser-wasm/internal/graph/full_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
66
visualiser-wasm/internal/hashpos/hashpos.go
Normal file
66
visualiser-wasm/internal/hashpos/hashpos.go
Normal 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
|
||||
}
|
||||
127
visualiser-wasm/internal/hashpos/hashpos_test.go
Normal file
127
visualiser-wasm/internal/hashpos/hashpos_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
70
visualiser-wasm/internal/icon/dedupe.go
Normal file
70
visualiser-wasm/internal/icon/dedupe.go
Normal 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
|
||||
}
|
||||
114
visualiser-wasm/internal/icon/dedupe_test.go
Normal file
114
visualiser-wasm/internal/icon/dedupe_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
247
visualiser-wasm/internal/layout/force.go
Normal file
247
visualiser-wasm/internal/layout/force.go
Normal 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
|
||||
}
|
||||
79
visualiser-wasm/internal/layout/force_test.go
Normal file
79
visualiser-wasm/internal/layout/force_test.go
Normal 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
|
||||
}
|
||||
34
visualiser-wasm/internal/leaktest/leaktest.go
Normal file
34
visualiser-wasm/internal/leaktest/leaktest.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
189
visualiser-wasm/internal/lod/lod.go
Normal file
189
visualiser-wasm/internal/lod/lod.go
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
157
visualiser-wasm/internal/lod/lod_test.go
Normal file
157
visualiser-wasm/internal/lod/lod_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
112
vite.config.js
112
vite.config.js
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue