mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: add system resource monitoring with CPU and memory usage insights in the UI
This commit is contained in:
parent
e1cb2d9ccc
commit
d801d095de
20 changed files with 912 additions and 332 deletions
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -524,6 +524,15 @@ class ReticulumMeshChat:
|
|||
broadcast_event=self._on_rns_link_broadcast,
|
||||
)
|
||||
self.memory_pressure = MemoryPressureManager(app=self)
|
||||
from meshchatx.src.backend.battery_usage_estimate import BatteryUsageTracker
|
||||
|
||||
self.battery_usage = BatteryUsageTracker()
|
||||
try:
|
||||
self._host_process = psutil.Process()
|
||||
# Prime cpu_percent so later non-blocking samples are meaningful.
|
||||
self._host_process.cpu_percent(interval=None)
|
||||
except Exception:
|
||||
self._host_process = None
|
||||
# Track long-running rns.link.* handler tasks per WS client so they can
|
||||
# be cancelled when the client disconnects.
|
||||
self._rns_link_tasks: dict[web.WebSocketResponse, set[asyncio.Task]] = {}
|
||||
|
|
@ -7366,9 +7375,21 @@ class ReticulumMeshChat:
|
|||
# get app info
|
||||
@routes.get("/api/v1/app/info")
|
||||
async def app_info(request):
|
||||
process = psutil.Process()
|
||||
process = getattr(self, "_host_process", None)
|
||||
if process is None:
|
||||
try:
|
||||
process = psutil.Process()
|
||||
except Exception:
|
||||
process = None
|
||||
|
||||
def _safe_memory_info():
|
||||
if process is None:
|
||||
|
||||
class _M:
|
||||
rss = 0
|
||||
vms = 0
|
||||
|
||||
return _M()
|
||||
try:
|
||||
return process.memory_info()
|
||||
except Exception:
|
||||
|
|
@ -7379,6 +7400,43 @@ class ReticulumMeshChat:
|
|||
|
||||
return _M()
|
||||
|
||||
def _safe_process_usage():
|
||||
usage = {
|
||||
"cpu_percent": None,
|
||||
"num_threads": None,
|
||||
"create_time": None,
|
||||
"cpu_time_seconds": None,
|
||||
}
|
||||
if process is None:
|
||||
return usage
|
||||
try:
|
||||
usage["cpu_percent"] = float(process.cpu_percent(interval=None))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
usage["num_threads"] = int(process.num_threads())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
usage["create_time"] = float(process.create_time())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
times = process.cpu_times()
|
||||
usage["cpu_time_seconds"] = float(times.user) + float(times.system)
|
||||
except Exception:
|
||||
pass
|
||||
return usage
|
||||
|
||||
def _safe_battery_usage():
|
||||
tracker = getattr(self, "battery_usage", None)
|
||||
if tracker is None:
|
||||
return None
|
||||
try:
|
||||
return tracker.snapshot(process)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _safe_net_io():
|
||||
try:
|
||||
return psutil.net_io_counters()
|
||||
|
|
@ -7394,6 +7452,8 @@ class ReticulumMeshChat:
|
|||
|
||||
# psutil often raises on Android (restricted /proc), so never fail the whole payload.
|
||||
memory_info = _safe_memory_info()
|
||||
process_usage = _safe_process_usage()
|
||||
battery_usage = _safe_battery_usage()
|
||||
net_io = _safe_net_io()
|
||||
|
||||
def _safe_database_path():
|
||||
|
|
@ -7451,26 +7511,31 @@ class ReticulumMeshChat:
|
|||
if is_connected_to_shared_instance:
|
||||
# Try to find the shared instance address from active connections
|
||||
try:
|
||||
for conn in process.net_connections(kind="all"):
|
||||
if conn.status == psutil.CONN_ESTABLISHED and conn.raddr:
|
||||
# Check for common Reticulum shared instance ports or UNIX sockets
|
||||
if process is not None:
|
||||
for conn in process.net_connections(kind="all"):
|
||||
if (
|
||||
isinstance(conn.raddr, tuple)
|
||||
and conn.raddr[1] == 37428
|
||||
conn.status == psutil.CONN_ESTABLISHED
|
||||
and conn.raddr
|
||||
):
|
||||
shared_instance_address = (
|
||||
f"{conn.raddr[0]}:{conn.raddr[1]}"
|
||||
)
|
||||
break
|
||||
if (
|
||||
isinstance(conn.raddr, str)
|
||||
and (
|
||||
"rns" in conn.raddr or "reticulum" in conn.raddr
|
||||
)
|
||||
and ".sock" in conn.raddr
|
||||
):
|
||||
shared_instance_address = conn.raddr
|
||||
break
|
||||
# Check for common Reticulum shared instance ports or UNIX sockets
|
||||
if (
|
||||
isinstance(conn.raddr, tuple)
|
||||
and conn.raddr[1] == 37428
|
||||
):
|
||||
shared_instance_address = (
|
||||
f"{conn.raddr[0]}:{conn.raddr[1]}"
|
||||
)
|
||||
break
|
||||
if (
|
||||
isinstance(conn.raddr, str)
|
||||
and (
|
||||
"rns" in conn.raddr
|
||||
or "reticulum" in conn.raddr
|
||||
)
|
||||
and ".sock" in conn.raddr
|
||||
):
|
||||
shared_instance_address = conn.raddr
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -7612,9 +7677,14 @@ class ReticulumMeshChat:
|
|||
else False
|
||||
),
|
||||
"memory_usage": {
|
||||
"rss": memory_info.rss, # Resident Set Size (bytes)
|
||||
"vms": memory_info.vms, # Virtual Memory Size (bytes)
|
||||
"rss": memory_info.rss,
|
||||
"vms": memory_info.vms,
|
||||
"cpu_percent": process_usage.get("cpu_percent"),
|
||||
"num_threads": process_usage.get("num_threads"),
|
||||
"create_time": process_usage.get("create_time"),
|
||||
"cpu_time_seconds": process_usage.get("cpu_time_seconds"),
|
||||
},
|
||||
"battery_usage": battery_usage,
|
||||
"network_stats": {
|
||||
"bytes_sent": net_io.bytes_sent,
|
||||
"bytes_recv": net_io.bytes_recv,
|
||||
|
|
|
|||
195
meshchatx/src/backend/battery_usage_estimate.py
Normal file
195
meshchatx/src/backend/battery_usage_estimate.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
"""Estimate MeshChatX battery drain from process CPU time.
|
||||
|
||||
OS APIs rarely expose per-app battery without privileged permissions.
|
||||
This module derives a conservative estimate from cumulative process CPU
|
||||
time versus wall-clock uptime so About can show app-level usage instead
|
||||
of only the host pack percentage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
# Rough calibration: one busy logical core ~ this many battery percent per hour
|
||||
# on a typical phone or thin laptop. Tuned to be conservative and capped.
|
||||
_ONE_CORE_PERCENT_PER_HOUR = 10.0
|
||||
_MAX_PERCENT_PER_HOUR = 40.0
|
||||
_MIN_UPTIME_FOR_RATE_S = 30.0
|
||||
|
||||
|
||||
def _clamp(value: float, low: float, high: float) -> float:
|
||||
return max(low, min(high, value))
|
||||
|
||||
|
||||
def drain_intensity(estimated_percent_per_hour: float | None) -> str | None:
|
||||
"""Map an estimated %/hr rate to a coarse intensity label."""
|
||||
if estimated_percent_per_hour is None:
|
||||
return None
|
||||
if estimated_percent_per_hour < 0.5:
|
||||
return "low"
|
||||
if estimated_percent_per_hour < 2.0:
|
||||
return "moderate"
|
||||
if estimated_percent_per_hour < 6.0:
|
||||
return "high"
|
||||
return "very_high"
|
||||
|
||||
|
||||
def estimate_battery_usage(
|
||||
*,
|
||||
cpu_time_seconds: float | None,
|
||||
uptime_seconds: float | None,
|
||||
cpu_count: int | None = 1,
|
||||
on_battery: bool | None = None,
|
||||
host_level: int | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Build an estimated MeshChatX battery-usage payload.
|
||||
|
||||
``avg_cpu_percent`` is percent of one logical core (may exceed 100 on
|
||||
multi-threaded work). ``machine_share_percent`` normalizes by CPU count.
|
||||
``estimated_percent_per_hour`` is a rough battery pack drain rate.
|
||||
"""
|
||||
if cpu_time_seconds is None or uptime_seconds is None:
|
||||
return None
|
||||
try:
|
||||
cpu_time = float(cpu_time_seconds)
|
||||
uptime = float(uptime_seconds)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if cpu_time < 0 or uptime <= 0:
|
||||
return None
|
||||
|
||||
cores = 1
|
||||
if cpu_count is not None:
|
||||
try:
|
||||
cores = max(1, int(cpu_count))
|
||||
except (TypeError, ValueError):
|
||||
cores = 1
|
||||
|
||||
one_core_fraction = cpu_time / uptime
|
||||
avg_cpu_percent = one_core_fraction * 100.0
|
||||
machine_share_percent = _clamp((one_core_fraction / cores) * 100.0, 0.0, 100.0)
|
||||
|
||||
estimated_percent_per_hour = None
|
||||
confidence = "warming_up"
|
||||
if uptime >= _MIN_UPTIME_FOR_RATE_S:
|
||||
estimated_percent_per_hour = _clamp(
|
||||
one_core_fraction * _ONE_CORE_PERCENT_PER_HOUR,
|
||||
0.0,
|
||||
_MAX_PERCENT_PER_HOUR,
|
||||
)
|
||||
confidence = "estimate"
|
||||
|
||||
intensity = drain_intensity(estimated_percent_per_hour)
|
||||
|
||||
return {
|
||||
"avg_cpu_percent": round(avg_cpu_percent, 1),
|
||||
"machine_share_percent": round(machine_share_percent, 1),
|
||||
"estimated_percent_per_hour": (
|
||||
round(estimated_percent_per_hour, 1)
|
||||
if estimated_percent_per_hour is not None
|
||||
else None
|
||||
),
|
||||
"intensity": intensity,
|
||||
"cpu_time_seconds": round(cpu_time, 2),
|
||||
"uptime_seconds": round(uptime, 1),
|
||||
"cpu_count": cores,
|
||||
"method": "cpu_time",
|
||||
"confidence": confidence,
|
||||
"on_battery": on_battery,
|
||||
"host_level": host_level,
|
||||
}
|
||||
|
||||
|
||||
def read_linux_host_battery() -> tuple[int | None, bool | None]:
|
||||
"""Best-effort host pack reading from sysfs (Linux laptops and some SBCs)."""
|
||||
power_root = "/sys/class/power_supply"
|
||||
if not os.path.isdir(power_root):
|
||||
return None, None
|
||||
try:
|
||||
entries = sorted(os.listdir(power_root))
|
||||
except OSError:
|
||||
return None, None
|
||||
|
||||
level = None
|
||||
charging = None
|
||||
for name in entries:
|
||||
base = os.path.join(power_root, name)
|
||||
type_path = os.path.join(base, "type")
|
||||
try:
|
||||
with open(type_path, encoding="utf-8") as handle:
|
||||
supply_type = handle.read().strip().lower()
|
||||
except OSError:
|
||||
continue
|
||||
if supply_type != "battery":
|
||||
continue
|
||||
capacity_path = os.path.join(base, "capacity")
|
||||
status_path = os.path.join(base, "status")
|
||||
try:
|
||||
with open(capacity_path, encoding="utf-8") as handle:
|
||||
raw = int(handle.read().strip())
|
||||
if 0 <= raw <= 100:
|
||||
level = raw
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
try:
|
||||
with open(status_path, encoding="utf-8") as handle:
|
||||
status = handle.read().strip().lower()
|
||||
if status in ("charging", "full"):
|
||||
charging = True
|
||||
elif status in ("discharging", "not charging"):
|
||||
charging = False
|
||||
except OSError:
|
||||
pass
|
||||
if level is not None:
|
||||
break
|
||||
return level, charging
|
||||
|
||||
|
||||
class BatteryUsageTracker:
|
||||
"""Snapshot MeshChatX process CPU into a battery-usage estimate."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._last: dict[str, Any] | None = None
|
||||
|
||||
def snapshot(self, process: Any) -> dict[str, Any] | None:
|
||||
if process is None:
|
||||
return self._last
|
||||
try:
|
||||
times = process.cpu_times()
|
||||
cpu_time = float(getattr(times, "user", 0.0)) + float(
|
||||
getattr(times, "system", 0.0)
|
||||
)
|
||||
except Exception:
|
||||
return self._last
|
||||
try:
|
||||
create_time = float(process.create_time())
|
||||
except Exception:
|
||||
return self._last
|
||||
uptime = max(0.0, time.time() - create_time)
|
||||
try:
|
||||
import psutil
|
||||
|
||||
cpu_count = psutil.cpu_count(logical=True) or 1
|
||||
except Exception:
|
||||
cpu_count = 1
|
||||
|
||||
host_level, charging = read_linux_host_battery()
|
||||
on_battery = None
|
||||
if charging is True:
|
||||
on_battery = False
|
||||
elif charging is False:
|
||||
on_battery = True
|
||||
|
||||
estimate = estimate_battery_usage(
|
||||
cpu_time_seconds=cpu_time,
|
||||
uptime_seconds=uptime,
|
||||
cpu_count=cpu_count,
|
||||
on_battery=on_battery,
|
||||
host_level=host_level,
|
||||
)
|
||||
if estimate is not None:
|
||||
self._last = estimate
|
||||
return estimate
|
||||
|
|
@ -91,7 +91,6 @@
|
|||
/>
|
||||
</button>
|
||||
<LanguageSelector class="hidden sm:block" @language-change="onLanguageChange" />
|
||||
<BatteryStatusChip />
|
||||
<NotificationBell />
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -595,7 +594,6 @@ import MaterialDesignIcon from "./MaterialDesignIcon.vue";
|
|||
import QRCode from "qrcode";
|
||||
import NotificationBell from "./NotificationBell.vue";
|
||||
import LanguageSelector from "./LanguageSelector.vue";
|
||||
import BatteryStatusChip from "./layout/BatteryStatusChip.vue";
|
||||
import CallOverlay from "./call/CallOverlay.vue";
|
||||
import CommandPalette from "./CommandPalette.vue";
|
||||
import IntegrityWarningModal from "./IntegrityWarningModal.vue";
|
||||
|
|
@ -625,7 +623,6 @@ export default {
|
|||
MaterialDesignIcon,
|
||||
NotificationBell,
|
||||
LanguageSelector,
|
||||
BatteryStatusChip,
|
||||
CallOverlay,
|
||||
CommandPalette,
|
||||
IntegrityWarningModal,
|
||||
|
|
|
|||
|
|
@ -459,21 +459,103 @@
|
|||
}}</span>
|
||||
<span class="font-mono text-xs font-bold">{{ environmentInfo.platform }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-[10px] font-black text-lime-600 uppercase tracking-wider">{{
|
||||
$t("about.env_battery")
|
||||
<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>
|
||||
<span
|
||||
class="font-mono text-xs font-bold shrink-0 inline-flex items-center gap-1"
|
||||
:class="batteryStatusToneClass"
|
||||
<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"
|
||||
>
|
||||
<v-icon
|
||||
v-if="batteryStatus"
|
||||
:icon="'mdi-' + batteryStatusIcon"
|
||||
size="14"
|
||||
></v-icon>
|
||||
{{ batteryStatusLabel }}
|
||||
</span>
|
||||
<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"
|
||||
|
|
@ -1089,7 +1171,15 @@ import DialogUtils from "../../js/DialogUtils";
|
|||
import ToastUtils from "../../js/ToastUtils";
|
||||
import DownloadUtils from "../../js/DownloadUtils";
|
||||
import GlobalEmitter from "../../js/GlobalEmitter";
|
||||
import { batteryStatusIconName, getDeviceBatteryStatus } from "../../js/deviceBattery.js";
|
||||
import {
|
||||
appBatteryUsageToneClass,
|
||||
batteryStatusIconName,
|
||||
formatAppBatteryShareLabel,
|
||||
formatAppBatteryUsageLabel,
|
||||
formatProcessUptime,
|
||||
getDeviceBatteryStatus,
|
||||
isNativeBatteryStatus,
|
||||
} from "../../js/deviceBattery.js";
|
||||
export default {
|
||||
name: "AboutPage",
|
||||
components: {},
|
||||
|
|
@ -1182,6 +1272,35 @@ export default {
|
|||
batteryStatusIcon() {
|
||||
return batteryStatusIconName(this.batteryStatus);
|
||||
},
|
||||
showHostBattery() {
|
||||
return isNativeBatteryStatus(this.batteryStatus);
|
||||
},
|
||||
batteryUsageLabel() {
|
||||
return formatAppBatteryUsageLabel(this.appInfo?.battery_usage, (key, values) => this.$t(key, values));
|
||||
},
|
||||
batteryUsageShareLabel() {
|
||||
return formatAppBatteryShareLabel(this.appInfo?.battery_usage, (key, values) => this.$t(key, values));
|
||||
},
|
||||
batteryUsageToneClass() {
|
||||
return appBatteryUsageToneClass(this.appInfo?.battery_usage);
|
||||
},
|
||||
processUptimeLabel() {
|
||||
return formatProcessUptime(this.appInfo?.memory_usage?.create_time);
|
||||
},
|
||||
memoryPressureLabel() {
|
||||
const cleanup = this.appInfo?.reticulum_stats?.memory_cleanup;
|
||||
if (!cleanup || typeof cleanup !== "object") {
|
||||
return null;
|
||||
}
|
||||
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 null;
|
||||
},
|
||||
batteryStatusLabel() {
|
||||
if (!this.batteryStatus || !this.batteryStatus.supported) {
|
||||
return this.$t("about.env_battery_unavailable");
|
||||
|
|
@ -1661,6 +1780,13 @@ export default {
|
|||
formatBytes: function (bytes) {
|
||||
return Utils.formatBytes(bytes);
|
||||
},
|
||||
formatCpuPercent(value) {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) {
|
||||
return this.$t("about.path_unknown");
|
||||
}
|
||||
return `${n.toFixed(n >= 10 ? 0 : 1)}%`;
|
||||
},
|
||||
formatNumber: function (num) {
|
||||
return Utils.formatNumber(num);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
<template>
|
||||
<button
|
||||
v-if="visible"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-semibold tabular-nums transition-colors"
|
||||
:class="chipClass"
|
||||
:title="titleText"
|
||||
:aria-label="titleText"
|
||||
@click="onClick"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="iconName" class="h-4 w-4 shrink-0" />
|
||||
<span>{{ levelLabel }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import { batteryStatusIconName, getDeviceBatteryStatus, shouldShowBatteryChip } from "../../js/deviceBattery.js";
|
||||
|
||||
const POLL_MS = 60000;
|
||||
|
||||
export default {
|
||||
name: "BatteryStatusChip",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
emits: ["open-about"],
|
||||
data() {
|
||||
return {
|
||||
status: null,
|
||||
pollTimer: null,
|
||||
webBattery: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
visible() {
|
||||
return shouldShowBatteryChip(this.status);
|
||||
},
|
||||
iconName() {
|
||||
return batteryStatusIconName(this.status);
|
||||
},
|
||||
levelLabel() {
|
||||
if (this.status?.level == null) {
|
||||
return "";
|
||||
}
|
||||
return `${this.status.level}%`;
|
||||
},
|
||||
titleText() {
|
||||
if (!this.status?.supported) {
|
||||
return this.$t("app.battery_unavailable");
|
||||
}
|
||||
const level = this.status.level != null ? `${this.status.level}%` : this.$t("about.path_unknown");
|
||||
if (this.status.charging === true) {
|
||||
return this.$t("app.battery_charging_title", { percent: level });
|
||||
}
|
||||
if (this.status.charging === false) {
|
||||
return this.$t("app.battery_discharging_title", { percent: level });
|
||||
}
|
||||
return this.$t("app.battery_level_title", { percent: level });
|
||||
},
|
||||
chipClass() {
|
||||
const level = this.status?.level;
|
||||
if (this.status?.charging) {
|
||||
return "text-emerald-700 dark:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-950/40";
|
||||
}
|
||||
if (level != null && level <= 15) {
|
||||
return "text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950/40";
|
||||
}
|
||||
if (level != null && level <= 30) {
|
||||
return "text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-950/40";
|
||||
}
|
||||
return "text-gray-700 dark:text-zinc-200 hover:bg-gray-100 dark:hover:bg-zinc-800";
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.refresh();
|
||||
this.pollTimer = setInterval(() => {
|
||||
this.refresh();
|
||||
}, POLL_MS);
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
this.detachWebBatteryListeners();
|
||||
},
|
||||
methods: {
|
||||
onClick() {
|
||||
this.$emit("open-about");
|
||||
if (this.$router) {
|
||||
this.$router.push({ name: "about" });
|
||||
}
|
||||
},
|
||||
detachWebBatteryListeners() {
|
||||
if (!this.webBattery) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.webBattery.removeEventListener("levelchange", this.onWebBatteryChange);
|
||||
this.webBattery.removeEventListener("chargingchange", this.onWebBatteryChange);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.webBattery = null;
|
||||
},
|
||||
onWebBatteryChange() {
|
||||
this.refresh();
|
||||
},
|
||||
async attachWebBatteryListeners() {
|
||||
if (this.webBattery) {
|
||||
return;
|
||||
}
|
||||
if (typeof navigator === "undefined" || typeof navigator.getBattery !== "function") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const battery = await navigator.getBattery();
|
||||
if (!battery) {
|
||||
return;
|
||||
}
|
||||
this.webBattery = battery;
|
||||
battery.addEventListener("levelchange", this.onWebBatteryChange);
|
||||
battery.addEventListener("chargingchange", this.onWebBatteryChange);
|
||||
} catch {
|
||||
// Browser may deny Battery Status API.
|
||||
}
|
||||
},
|
||||
async refresh() {
|
||||
try {
|
||||
this.status = await getDeviceBatteryStatus();
|
||||
if (this.status?.source === "web") {
|
||||
await this.attachWebBatteryListeners();
|
||||
}
|
||||
} catch {
|
||||
this.status = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
/**
|
||||
* Host device battery probe for laptops and mobile.
|
||||
* Host device battery probe for laptops and mobile shells.
|
||||
*
|
||||
* Order: Android bridge, Electron IPC, then Chromium Battery Status API.
|
||||
* Returns null when the runtime cannot expose battery state.
|
||||
* Order: Android bridge, then Electron IPC.
|
||||
* The Chromium Battery Status API is opt-in only. Headless and Docker
|
||||
* Chromium builds often report a fake "charging 100%" reading.
|
||||
*/
|
||||
|
||||
import AndroidBridge from "./rnode/AndroidBridge.js";
|
||||
|
|
@ -160,11 +161,16 @@ async function probeWebBattery() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Read current host battery status when the platform supports it.
|
||||
* Read host battery when a native shell exposes it.
|
||||
*
|
||||
* Web Battery Status is off by default (Docker / headless Chromium lies).
|
||||
* Pass `{ allowWeb: true }` only when the caller accepts that risk.
|
||||
*
|
||||
* @param {{ allowWeb?: boolean }} [options]
|
||||
* @returns {Promise<DeviceBatteryStatus|null>}
|
||||
*/
|
||||
export async function getDeviceBatteryStatus() {
|
||||
export async function getDeviceBatteryStatus(options = {}) {
|
||||
const allowWeb = Boolean(options.allowWeb);
|
||||
const androidStatus = await probeAndroidBattery();
|
||||
if (androidStatus) {
|
||||
return androidStatus;
|
||||
|
|
@ -173,7 +179,10 @@ export async function getDeviceBatteryStatus() {
|
|||
if (electronStatus) {
|
||||
return electronStatus;
|
||||
}
|
||||
return probeWebBattery();
|
||||
if (allowWeb) {
|
||||
return probeWebBattery();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -206,11 +215,114 @@ export function batteryStatusIconName(status) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Whether the header chip should be visible.
|
||||
* Whether a reading came from a native host shell (not browser fakes).
|
||||
*
|
||||
* @param {DeviceBatteryStatus|null|undefined} status
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldShowBatteryChip(status) {
|
||||
return Boolean(status && status.supported && status.level != null);
|
||||
export function isNativeBatteryStatus(status) {
|
||||
return Boolean(status && status.supported && (status.source === "android" || status.source === "electron"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a process create_time (unix seconds) as a short uptime label.
|
||||
*
|
||||
* @param {unknown} createTime
|
||||
* @param {number} [nowMs]
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function formatProcessUptime(createTime, nowMs = Date.now()) {
|
||||
const started = typeof createTime === "number" ? createTime : Number(createTime);
|
||||
if (!Number.isFinite(started) || started <= 0) {
|
||||
return null;
|
||||
}
|
||||
const seconds = Math.max(0, Math.floor(nowMs / 1000 - started));
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`;
|
||||
}
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} AppBatteryUsage
|
||||
* @property {number|null} [estimated_percent_per_hour]
|
||||
* @property {string|null} [intensity]
|
||||
* @property {number|null} [machine_share_percent]
|
||||
* @property {number|null} [avg_cpu_percent]
|
||||
* @property {string|null} [confidence]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Primary label for estimated MeshChatX battery drain.
|
||||
*
|
||||
* @param {AppBatteryUsage|null|undefined} usage
|
||||
* @param {(key: string, values?: object) => string} t
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function formatAppBatteryUsageLabel(usage, t) {
|
||||
if (!usage || typeof usage !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (usage.confidence === "warming_up" || usage.estimated_percent_per_hour == null) {
|
||||
return t("about.app_battery_use_warming");
|
||||
}
|
||||
const rate = Number(usage.estimated_percent_per_hour);
|
||||
if (!Number.isFinite(rate)) {
|
||||
return null;
|
||||
}
|
||||
const rateText = `${rate.toFixed(rate >= 10 ? 0 : 1)}%/hr`;
|
||||
if (usage.intensity) {
|
||||
return t("about.app_battery_use_with_intensity", {
|
||||
rate: rateText,
|
||||
intensity: t(`about.app_battery_intensity_${usage.intensity}`),
|
||||
});
|
||||
}
|
||||
return t("about.app_battery_use_rate", { rate: rateText });
|
||||
}
|
||||
|
||||
/**
|
||||
* Secondary label for MeshChatX share of device CPU capacity.
|
||||
*
|
||||
* @param {AppBatteryUsage|null|undefined} usage
|
||||
* @param {(key: string, values?: object) => string} t
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function formatAppBatteryShareLabel(usage, t) {
|
||||
if (!usage || typeof usage !== "object") {
|
||||
return null;
|
||||
}
|
||||
const share = Number(usage.machine_share_percent);
|
||||
if (!Number.isFinite(share)) {
|
||||
return null;
|
||||
}
|
||||
return t("about.app_battery_share_value", {
|
||||
percent: share.toFixed(share >= 10 ? 0 : 1),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tone class for estimated drain intensity.
|
||||
*
|
||||
* @param {AppBatteryUsage|null|undefined} usage
|
||||
* @returns {string}
|
||||
*/
|
||||
export function appBatteryUsageToneClass(usage) {
|
||||
const intensity = usage?.intensity;
|
||||
if (intensity === "very_high" || intensity === "high") {
|
||||
return "text-amber-700 dark:text-amber-300";
|
||||
}
|
||||
if (intensity === "moderate") {
|
||||
return "text-sky-700 dark:text-sky-300";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1189,7 +1189,26 @@
|
|||
"env_battery": "Akku",
|
||||
"env_battery_unavailable": "Auf diesem Gerät nicht verfügbar",
|
||||
"env_battery_charging": "Lädt {percent}",
|
||||
"env_battery_on_battery": "Akku {percent}"
|
||||
"env_battery_on_battery": "Akku {percent}",
|
||||
"usage_insights": "MeshChatX-Auslastung",
|
||||
"process_cpu": "Prozess-CPU",
|
||||
"process_threads": "Threads",
|
||||
"process_uptime": "Laufzeit",
|
||||
"memory_pressure": "Speicherdruck",
|
||||
"memory_pressure_relaxed": "Entspannt (wenig Speicher)",
|
||||
"memory_pressure_paths": "{count} Pfade verfolgt",
|
||||
"env_host_battery": "Host-Akku",
|
||||
"app_battery_use": "Gesch. MeshChatX-Akku",
|
||||
"app_battery_use_hint": "Schatzung aus MeshChatX-CPU-Zeit seit Start. Keine OS-Akku-Zuordnung.",
|
||||
"app_battery_use_warming": "Messung…",
|
||||
"app_battery_use_rate": "~{rate}",
|
||||
"app_battery_use_with_intensity": "~{rate} ({intensity})",
|
||||
"app_battery_share": "Gesch. CPU-Anteil",
|
||||
"app_battery_share_value": "{percent}% des Gerats",
|
||||
"app_battery_intensity_low": "niedrig",
|
||||
"app_battery_intensity_moderate": "mittel",
|
||||
"app_battery_intensity_high": "hoch",
|
||||
"app_battery_intensity_very_high": "sehr hoch"
|
||||
},
|
||||
"interfaces": {
|
||||
"title": "Schnittstellen",
|
||||
|
|
|
|||
|
|
@ -1137,7 +1137,26 @@
|
|||
"env_battery": "Battery",
|
||||
"env_battery_unavailable": "Unavailable on this device",
|
||||
"env_battery_charging": "Charging {percent}",
|
||||
"env_battery_on_battery": "On battery {percent}"
|
||||
"env_battery_on_battery": "On battery {percent}",
|
||||
"usage_insights": "MeshChatX usage",
|
||||
"process_cpu": "Process CPU",
|
||||
"process_threads": "Threads",
|
||||
"process_uptime": "Uptime",
|
||||
"memory_pressure": "Memory pressure",
|
||||
"memory_pressure_relaxed": "Relaxed (low memory)",
|
||||
"memory_pressure_paths": "{count} paths tracked",
|
||||
"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.",
|
||||
"app_battery_use_warming": "Measuring…",
|
||||
"app_battery_use_rate": "~{rate}",
|
||||
"app_battery_use_with_intensity": "~{rate} ({intensity})",
|
||||
"app_battery_share": "Est. CPU share",
|
||||
"app_battery_share_value": "{percent}% of device",
|
||||
"app_battery_intensity_low": "low",
|
||||
"app_battery_intensity_moderate": "moderate",
|
||||
"app_battery_intensity_high": "high",
|
||||
"app_battery_intensity_very_high": "very high"
|
||||
},
|
||||
"interfaces": {
|
||||
"title": "Interfaces",
|
||||
|
|
|
|||
|
|
@ -1137,7 +1137,26 @@
|
|||
"env_battery": "Batería",
|
||||
"env_battery_unavailable": "No disponible en este dispositivo",
|
||||
"env_battery_charging": "Cargando {percent}",
|
||||
"env_battery_on_battery": "En batería {percent}"
|
||||
"env_battery_on_battery": "En batería {percent}",
|
||||
"usage_insights": "Uso de MeshChatX",
|
||||
"process_cpu": "CPU del proceso",
|
||||
"process_threads": "Hilos",
|
||||
"process_uptime": "Tiempo activo",
|
||||
"memory_pressure": "Presion de memoria",
|
||||
"memory_pressure_relaxed": "Relajado (poca memoria)",
|
||||
"memory_pressure_paths": "{count} rutas rastreadas",
|
||||
"env_host_battery": "Bateria del host",
|
||||
"app_battery_use": "Bat. est. MeshChatX",
|
||||
"app_battery_use_hint": "Estimacion por tiempo de CPU de MeshChatX desde el inicio. No es atribucion del SO.",
|
||||
"app_battery_use_warming": "Midiendo…",
|
||||
"app_battery_use_rate": "~{rate}",
|
||||
"app_battery_use_with_intensity": "~{rate} ({intensity})",
|
||||
"app_battery_share": "Cuota CPU est.",
|
||||
"app_battery_share_value": "{percent}% del dispositivo",
|
||||
"app_battery_intensity_low": "baja",
|
||||
"app_battery_intensity_moderate": "moderada",
|
||||
"app_battery_intensity_high": "alta",
|
||||
"app_battery_intensity_very_high": "muy alta"
|
||||
},
|
||||
"interfaces": {
|
||||
"title": "Interfaces",
|
||||
|
|
|
|||
|
|
@ -1137,7 +1137,26 @@
|
|||
"env_battery": "Akku",
|
||||
"env_battery_unavailable": "Ei saatavilla tällä laitteella",
|
||||
"env_battery_charging": "Lataa {percent}",
|
||||
"env_battery_on_battery": "Akulla {percent}"
|
||||
"env_battery_on_battery": "Akulla {percent}",
|
||||
"usage_insights": "MeshChatX-kaytto",
|
||||
"process_cpu": "Prosessin CPU",
|
||||
"process_threads": "Sailkeet",
|
||||
"process_uptime": "Kayttoaaika",
|
||||
"memory_pressure": "Muistipaine",
|
||||
"memory_pressure_relaxed": "Lievitetty (vahan muistia)",
|
||||
"memory_pressure_paths": "{count} polkua seurannassa",
|
||||
"env_host_battery": "Isannan akku",
|
||||
"app_battery_use": "Arvio MeshChatX-akku",
|
||||
"app_battery_use_hint": "Arvio MeshChatX-CPU-ajasta kaynnistyksesta. Ei kayttojarjestelman akkumattribuutiota.",
|
||||
"app_battery_use_warming": "Mitataan…",
|
||||
"app_battery_use_rate": "~{rate}",
|
||||
"app_battery_use_with_intensity": "~{rate} ({intensity})",
|
||||
"app_battery_share": "Arvio CPU-osuus",
|
||||
"app_battery_share_value": "{percent}% laitteesta",
|
||||
"app_battery_intensity_low": "matala",
|
||||
"app_battery_intensity_moderate": "kohtalainen",
|
||||
"app_battery_intensity_high": "korkea",
|
||||
"app_battery_intensity_very_high": "hyvin korkea"
|
||||
},
|
||||
"interfaces": {
|
||||
"title": "Sovittimet",
|
||||
|
|
|
|||
|
|
@ -1137,7 +1137,26 @@
|
|||
"env_battery": "Batterie",
|
||||
"env_battery_unavailable": "Indisponible sur cet appareil",
|
||||
"env_battery_charging": "En charge {percent}",
|
||||
"env_battery_on_battery": "Sur batterie {percent}"
|
||||
"env_battery_on_battery": "Sur batterie {percent}",
|
||||
"usage_insights": "Utilisation MeshChatX",
|
||||
"process_cpu": "CPU du processus",
|
||||
"process_threads": "Threads",
|
||||
"process_uptime": "Duree d'activite",
|
||||
"memory_pressure": "Pression memoire",
|
||||
"memory_pressure_relaxed": "Assoupli (memoire basse)",
|
||||
"memory_pressure_paths": "{count} chemins suivis",
|
||||
"env_host_battery": "Batterie hote",
|
||||
"app_battery_use": "Batt. est. MeshChatX",
|
||||
"app_battery_use_hint": "Estimation a partir du temps CPU MeshChatX depuis le demarrage. Pas une attribution OS.",
|
||||
"app_battery_use_warming": "Mesure…",
|
||||
"app_battery_use_rate": "~{rate}",
|
||||
"app_battery_use_with_intensity": "~{rate} ({intensity})",
|
||||
"app_battery_share": "Part CPU est.",
|
||||
"app_battery_share_value": "{percent}% de l'appareil",
|
||||
"app_battery_intensity_low": "faible",
|
||||
"app_battery_intensity_moderate": "moderee",
|
||||
"app_battery_intensity_high": "elevee",
|
||||
"app_battery_intensity_very_high": "tres elevee"
|
||||
},
|
||||
"interfaces": {
|
||||
"title": "Interfaces",
|
||||
|
|
|
|||
|
|
@ -1189,7 +1189,26 @@
|
|||
"env_battery": "Batteria",
|
||||
"env_battery_unavailable": "Non disponibile su questo dispositivo",
|
||||
"env_battery_charging": "In carica {percent}",
|
||||
"env_battery_on_battery": "A batteria {percent}"
|
||||
"env_battery_on_battery": "A batteria {percent}",
|
||||
"usage_insights": "Utilizzo MeshChatX",
|
||||
"process_cpu": "CPU processo",
|
||||
"process_threads": "Thread",
|
||||
"process_uptime": "Tempo di attivita",
|
||||
"memory_pressure": "Pressione memoria",
|
||||
"memory_pressure_relaxed": "Rilassato (poca memoria)",
|
||||
"memory_pressure_paths": "{count} percorsi tracciati",
|
||||
"env_host_battery": "Batteria host",
|
||||
"app_battery_use": "Batt. stim. MeshChatX",
|
||||
"app_battery_use_hint": "Stima dal tempo CPU di MeshChatX dall'avvio. Non e attribuzione del SO.",
|
||||
"app_battery_use_warming": "Misurazione…",
|
||||
"app_battery_use_rate": "~{rate}",
|
||||
"app_battery_use_with_intensity": "~{rate} ({intensity})",
|
||||
"app_battery_share": "Quota CPU stim.",
|
||||
"app_battery_share_value": "{percent}% del dispositivo",
|
||||
"app_battery_intensity_low": "bassa",
|
||||
"app_battery_intensity_moderate": "moderata",
|
||||
"app_battery_intensity_high": "alta",
|
||||
"app_battery_intensity_very_high": "molto alta"
|
||||
},
|
||||
"interfaces": {
|
||||
"title": "Interfacce",
|
||||
|
|
|
|||
|
|
@ -1137,7 +1137,26 @@
|
|||
"env_battery": "Batterij",
|
||||
"env_battery_unavailable": "Niet beschikbaar op dit apparaat",
|
||||
"env_battery_charging": "Opladen {percent}",
|
||||
"env_battery_on_battery": "Op batterij {percent}"
|
||||
"env_battery_on_battery": "Op batterij {percent}",
|
||||
"usage_insights": "MeshChatX-gebruik",
|
||||
"process_cpu": "Proces-CPU",
|
||||
"process_threads": "Threads",
|
||||
"process_uptime": "Uptime",
|
||||
"memory_pressure": "Geheugendruk",
|
||||
"memory_pressure_relaxed": "Versoepeld (weinig geheugen)",
|
||||
"memory_pressure_paths": "{count} paden gevolgd",
|
||||
"env_host_battery": "Hostbatterij",
|
||||
"app_battery_use": "Gesch. MeshChatX-batterij",
|
||||
"app_battery_use_hint": "Schatting uit MeshChatX-CPU-tijd sinds start. Geen OS-batterijtoewijzing.",
|
||||
"app_battery_use_warming": "Meten…",
|
||||
"app_battery_use_rate": "~{rate}",
|
||||
"app_battery_use_with_intensity": "~{rate} ({intensity})",
|
||||
"app_battery_share": "Gesch. CPU-aandeel",
|
||||
"app_battery_share_value": "{percent}% van apparaat",
|
||||
"app_battery_intensity_low": "laag",
|
||||
"app_battery_intensity_moderate": "matig",
|
||||
"app_battery_intensity_high": "hoog",
|
||||
"app_battery_intensity_very_high": "zeer hoog"
|
||||
},
|
||||
"interfaces": {
|
||||
"title": "Interfaces",
|
||||
|
|
|
|||
|
|
@ -1189,7 +1189,26 @@
|
|||
"env_battery": "Батарея",
|
||||
"env_battery_unavailable": "Недоступно на этом устройстве",
|
||||
"env_battery_charging": "Зарядка {percent}",
|
||||
"env_battery_on_battery": "От батареи {percent}"
|
||||
"env_battery_on_battery": "От батареи {percent}",
|
||||
"usage_insights": "Нагрузка MeshChatX",
|
||||
"process_cpu": "CPU процесса",
|
||||
"process_threads": "Потоки",
|
||||
"process_uptime": "Время работы",
|
||||
"memory_pressure": "Давление памяти",
|
||||
"memory_pressure_relaxed": "Ослаблен (мало памяти)",
|
||||
"memory_pressure_paths": "{count} путей отслеживается",
|
||||
"env_host_battery": "Батарея хоста",
|
||||
"app_battery_use": "Оц. батарея MeshChatX",
|
||||
"app_battery_use_hint": "Оценка по CPU-времени MeshChatX с запуска. Не системная атрибуция.",
|
||||
"app_battery_use_warming": "Измерение…",
|
||||
"app_battery_use_rate": "~{rate}",
|
||||
"app_battery_use_with_intensity": "~{rate} ({intensity})",
|
||||
"app_battery_share": "Оц. доля CPU",
|
||||
"app_battery_share_value": "{percent}% устройства",
|
||||
"app_battery_intensity_low": "низкая",
|
||||
"app_battery_intensity_moderate": "средняя",
|
||||
"app_battery_intensity_high": "высокая",
|
||||
"app_battery_intensity_very_high": "очень высокая"
|
||||
},
|
||||
"interfaces": {
|
||||
"title": "Интерфейсы",
|
||||
|
|
|
|||
|
|
@ -1137,7 +1137,26 @@
|
|||
"env_battery": "电池",
|
||||
"env_battery_unavailable": "此设备不可用",
|
||||
"env_battery_charging": "充电中 {percent}",
|
||||
"env_battery_on_battery": "使用电池 {percent}"
|
||||
"env_battery_on_battery": "使用电池 {percent}",
|
||||
"usage_insights": "MeshChatX 占用",
|
||||
"process_cpu": "进程 CPU",
|
||||
"process_threads": "线程",
|
||||
"process_uptime": "运行时间",
|
||||
"memory_pressure": "内存压力",
|
||||
"memory_pressure_relaxed": "已放宽(低内存)",
|
||||
"memory_pressure_paths": "已跟踪 {count} 条路径",
|
||||
"env_host_battery": "主机电池",
|
||||
"app_battery_use": "MeshChatX 预估耗电",
|
||||
"app_battery_use_hint": "根据 MeshChatX 启动以来的 CPU 时间估算,不是系统电池归因。",
|
||||
"app_battery_use_warming": "测量中…",
|
||||
"app_battery_use_rate": "~{rate}",
|
||||
"app_battery_use_with_intensity": "~{rate}({intensity})",
|
||||
"app_battery_share": "预估 CPU 占比",
|
||||
"app_battery_share_value": "设备的 {percent}%",
|
||||
"app_battery_intensity_low": "低",
|
||||
"app_battery_intensity_moderate": "中",
|
||||
"app_battery_intensity_high": "高",
|
||||
"app_battery_intensity_very_high": "很高"
|
||||
},
|
||||
"interfaces": {
|
||||
"title": "接口",
|
||||
|
|
|
|||
80
tests/backend/test_battery_usage_estimate.py
Normal file
80
tests/backend/test_battery_usage_estimate.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from meshchatx.src.backend.battery_usage_estimate import (
|
||||
BatteryUsageTracker,
|
||||
drain_intensity,
|
||||
estimate_battery_usage,
|
||||
)
|
||||
|
||||
|
||||
def test_estimate_scales_with_cpu_time():
|
||||
idle = estimate_battery_usage(
|
||||
cpu_time_seconds=6.0,
|
||||
uptime_seconds=600.0,
|
||||
cpu_count=4,
|
||||
)
|
||||
assert idle is not None
|
||||
assert idle["avg_cpu_percent"] == 1.0
|
||||
assert idle["machine_share_percent"] == 0.2
|
||||
assert idle["estimated_percent_per_hour"] == 0.1
|
||||
assert idle["intensity"] == "low"
|
||||
assert idle["confidence"] == "estimate"
|
||||
assert idle["method"] == "cpu_time"
|
||||
|
||||
busy = estimate_battery_usage(
|
||||
cpu_time_seconds=300.0,
|
||||
uptime_seconds=600.0,
|
||||
cpu_count=4,
|
||||
)
|
||||
assert busy is not None
|
||||
assert busy["avg_cpu_percent"] == 50.0
|
||||
assert busy["estimated_percent_per_hour"] == 5.0
|
||||
assert busy["intensity"] == "high"
|
||||
|
||||
|
||||
def test_estimate_warms_up_before_rate():
|
||||
early = estimate_battery_usage(
|
||||
cpu_time_seconds=1.0,
|
||||
uptime_seconds=10.0,
|
||||
cpu_count=2,
|
||||
)
|
||||
assert early is not None
|
||||
assert early["estimated_percent_per_hour"] is None
|
||||
assert early["confidence"] == "warming_up"
|
||||
assert early["intensity"] is None
|
||||
|
||||
|
||||
def test_estimate_rejects_bad_inputs():
|
||||
assert estimate_battery_usage(cpu_time_seconds=None, uptime_seconds=10) is None
|
||||
assert estimate_battery_usage(cpu_time_seconds=-1, uptime_seconds=10) is None
|
||||
assert estimate_battery_usage(cpu_time_seconds=1, uptime_seconds=0) is None
|
||||
|
||||
|
||||
def test_drain_intensity_buckets():
|
||||
assert drain_intensity(0.2) == "low"
|
||||
assert drain_intensity(1.0) == "moderate"
|
||||
assert drain_intensity(3.0) == "high"
|
||||
assert drain_intensity(9.0) == "very_high"
|
||||
assert drain_intensity(None) is None
|
||||
|
||||
|
||||
def test_tracker_snapshot_from_fake_process():
|
||||
class _Times:
|
||||
user = 12.0
|
||||
system = 3.0
|
||||
|
||||
class _Proc:
|
||||
def cpu_times(self):
|
||||
return _Times()
|
||||
|
||||
def create_time(self):
|
||||
import time
|
||||
|
||||
return time.time() - 120.0
|
||||
|
||||
tracker = BatteryUsageTracker()
|
||||
snap = tracker.snapshot(_Proc())
|
||||
assert snap is not None
|
||||
assert snap["cpu_time_seconds"] == 15.0
|
||||
assert snap["uptime_seconds"] >= 119.0
|
||||
assert snap["estimated_percent_per_hour"] is not None
|
||||
|
|
@ -518,13 +518,34 @@ describe("AboutPage.vue", () => {
|
|||
expect(wrapper.text()).not.toContain("app.landlock_status");
|
||||
});
|
||||
|
||||
it("loads and shows host battery status in environment info", async () => {
|
||||
navigator.getBattery = vi.fn(async () => ({ level: 0.81, charging: true }));
|
||||
|
||||
it("shows MeshChatX usage insights from app info", async () => {
|
||||
axiosMock.get.mockImplementation((url) => {
|
||||
if (url === "/api/v1/app/info") {
|
||||
return Promise.resolve({
|
||||
data: { app_info: { version: "1.0.0", host_platform: "linux" } },
|
||||
data: {
|
||||
app_info: {
|
||||
version: "1.0.0",
|
||||
host_platform: "linux",
|
||||
memory_usage: {
|
||||
rss: 128 * 1024 * 1024,
|
||||
vms: 256 * 1024 * 1024,
|
||||
cpu_percent: 2.5,
|
||||
num_threads: 18,
|
||||
create_time: Date.now() / 1000 - 125,
|
||||
},
|
||||
battery_usage: {
|
||||
avg_cpu_percent: 4.0,
|
||||
machine_share_percent: 1.0,
|
||||
estimated_percent_per_hour: 0.4,
|
||||
intensity: "low",
|
||||
confidence: "estimate",
|
||||
method: "cpu_time",
|
||||
},
|
||||
reticulum_stats: {
|
||||
memory_cleanup: { path_table_size: 42, sqlite_relaxed: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url === "/api/v1/config") return Promise.resolve({ data: { config: {} } });
|
||||
|
|
@ -536,11 +557,16 @@ describe("AboutPage.vue", () => {
|
|||
const wrapper = mountAboutPage();
|
||||
await vi.runOnlyPendingTimers();
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.refreshBatteryStatus();
|
||||
await wrapper.vm.getAppInfo();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain("about.env_battery");
|
||||
expect(wrapper.vm.batteryStatus?.level).toBe(81);
|
||||
expect(wrapper.vm.batteryStatusLabel).toContain("81%");
|
||||
expect(wrapper.text()).toContain("about.usage_insights");
|
||||
expect(wrapper.text()).toContain("about.app_battery_use");
|
||||
expect(wrapper.vm.batteryUsageLabel).toContain("0.4%/hr");
|
||||
expect(wrapper.text()).toContain("about.memory_rss");
|
||||
expect(wrapper.text()).toContain("about.process_cpu");
|
||||
expect(wrapper.vm.processUptimeLabel).toMatch(/2m/);
|
||||
expect(wrapper.vm.showHostBattery).toBe(false);
|
||||
expect(wrapper.text()).not.toContain("about.env_battery");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import BatteryStatusChip from "@/components/layout/BatteryStatusChip.vue";
|
||||
import * as deviceBattery from "@/js/deviceBattery.js";
|
||||
|
||||
vi.mock("@/js/deviceBattery.js", async () => {
|
||||
const actual = await vi.importActual("@/js/deviceBattery.js");
|
||||
return {
|
||||
...actual,
|
||||
getDeviceBatteryStatus: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("BatteryStatusChip.vue", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const mountChip = () =>
|
||||
mount(BatteryStatusChip, {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key),
|
||||
$router: { push: vi.fn() },
|
||||
},
|
||||
stubs: {
|
||||
MaterialDesignIcon: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it("hides when battery status is unavailable", async () => {
|
||||
deviceBattery.getDeviceBatteryStatus.mockResolvedValue(null);
|
||||
const wrapper = mountChip();
|
||||
await flushPromises();
|
||||
expect(wrapper.find("button").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("hides when probe throws", async () => {
|
||||
deviceBattery.getDeviceBatteryStatus.mockRejectedValue(new Error("boom"));
|
||||
const wrapper = mountChip();
|
||||
await flushPromises();
|
||||
expect(wrapper.find("button").exists()).toBe(false);
|
||||
expect(wrapper.vm.status).toBe(null);
|
||||
});
|
||||
|
||||
it("renders level and navigates to about on click", async () => {
|
||||
deviceBattery.getDeviceBatteryStatus.mockResolvedValue({
|
||||
supported: true,
|
||||
level: 42,
|
||||
charging: false,
|
||||
source: "web",
|
||||
});
|
||||
const wrapper = mountChip();
|
||||
await flushPromises();
|
||||
const button = wrapper.find("button");
|
||||
expect(button.exists()).toBe(true);
|
||||
expect(button.text()).toContain("42%");
|
||||
await button.trigger("click");
|
||||
expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ name: "about" });
|
||||
});
|
||||
|
||||
it("hides after a later refresh loses battery support", async () => {
|
||||
deviceBattery.getDeviceBatteryStatus
|
||||
.mockResolvedValueOnce({
|
||||
supported: true,
|
||||
level: 20,
|
||||
charging: false,
|
||||
source: "electron",
|
||||
})
|
||||
.mockResolvedValueOnce(null);
|
||||
const wrapper = mountChip();
|
||||
await flushPromises();
|
||||
expect(wrapper.find("button").exists()).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(60000);
|
||||
await flushPromises();
|
||||
expect(wrapper.find("button").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("shows low-battery styling under 15 percent", async () => {
|
||||
deviceBattery.getDeviceBatteryStatus.mockResolvedValue({
|
||||
supported: true,
|
||||
level: 8,
|
||||
charging: false,
|
||||
source: "android",
|
||||
});
|
||||
const wrapper = mountChip();
|
||||
await flushPromises();
|
||||
expect(wrapper.find("button").classes().join(" ")).toContain("text-red-700");
|
||||
});
|
||||
});
|
||||
|
|
@ -2,12 +2,16 @@
|
|||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
appBatteryUsageToneClass,
|
||||
batteryStatusIconName,
|
||||
formatAppBatteryShareLabel,
|
||||
formatAppBatteryUsageLabel,
|
||||
formatProcessUptime,
|
||||
getDeviceBatteryStatus,
|
||||
isNativeBatteryStatus,
|
||||
normalizeBatteryPercent,
|
||||
normalizeBatteryStatus,
|
||||
parseAndroidBatteryPayload,
|
||||
shouldShowBatteryChip,
|
||||
} from "@/js/deviceBattery.js";
|
||||
|
||||
describe("deviceBattery", () => {
|
||||
|
|
@ -82,20 +86,20 @@ describe("deviceBattery", () => {
|
|||
expect(normalizeBatteryStatus({ level: 10, is_charging: false }).charging).toBe(false);
|
||||
});
|
||||
|
||||
it("uses web scale when source is web so level 1 means 100%", () => {
|
||||
it("uses unitFraction only for web source when level is 1", () => {
|
||||
expect(normalizeBatteryStatus({ level: 1, charging: true }, "web").level).toBe(100);
|
||||
expect(normalizeBatteryStatus({ level: 1, charging: false }, "android").level).toBe(1);
|
||||
});
|
||||
|
||||
it("returns null when both level and charging are missing", () => {
|
||||
it("rejects empty objects without level or charging", () => {
|
||||
expect(normalizeBatteryStatus({ source: "web" })).toBe(null);
|
||||
expect(normalizeBatteryStatus({ level: "bad" })).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAndroidBatteryPayload edge cases", () => {
|
||||
it("handles object payloads and empty failures", () => {
|
||||
expect(parseAndroidBatteryPayload({ level: 9, charging: true })).toEqual({
|
||||
describe("parseAndroidBatteryPayload", () => {
|
||||
it("parses JSON strings and objects", () => {
|
||||
expect(parseAndroidBatteryPayload('{"level":9,"charging":true}')).toEqual({
|
||||
supported: true,
|
||||
level: 9,
|
||||
charging: true,
|
||||
|
|
@ -121,16 +125,50 @@ describe("deviceBattery", () => {
|
|||
expect(batteryStatusIconName({ supported: true, charging: false, level: 55 })).toBe("battery-medium");
|
||||
});
|
||||
|
||||
it("shows chip only when level is known", () => {
|
||||
expect(shouldShowBatteryChip(null)).toBe(false);
|
||||
expect(shouldShowBatteryChip({ supported: true, level: null })).toBe(false);
|
||||
expect(shouldShowBatteryChip({ supported: false, level: 50 })).toBe(false);
|
||||
expect(shouldShowBatteryChip({ supported: true, level: 0 })).toBe(true);
|
||||
expect(shouldShowBatteryChip({ supported: true, level: 55 })).toBe(true);
|
||||
it("marks only android and electron as native battery sources", () => {
|
||||
expect(isNativeBatteryStatus(null)).toBe(false);
|
||||
expect(isNativeBatteryStatus({ supported: true, source: "web", level: 100 })).toBe(false);
|
||||
expect(isNativeBatteryStatus({ supported: true, source: "android", level: 50 })).toBe(true);
|
||||
expect(isNativeBatteryStatus({ supported: true, source: "electron", level: 12 })).toBe(true);
|
||||
});
|
||||
|
||||
it("formats process uptime from create_time", () => {
|
||||
const now = 1_700_000_000_000;
|
||||
expect(formatProcessUptime(now / 1000 - 45, now)).toBe("45s");
|
||||
expect(formatProcessUptime(now / 1000 - 125, now)).toBe("2m 5s");
|
||||
expect(formatProcessUptime(now / 1000 - 3700, now)).toBe("1h 1m");
|
||||
expect(formatProcessUptime(now / 1000 - 90000, now)).toBe("1d 1h");
|
||||
expect(formatProcessUptime(null)).toBe(null);
|
||||
});
|
||||
|
||||
it("formats estimated app battery usage labels", () => {
|
||||
const t = (key, values = {}) => {
|
||||
if (key === "about.app_battery_use_warming") return "warming";
|
||||
if (key === "about.app_battery_use_with_intensity") {
|
||||
return `~${values.rate} (${values.intensity})`;
|
||||
}
|
||||
if (key === "about.app_battery_use_rate") return `~${values.rate}`;
|
||||
if (key.startsWith("about.app_battery_intensity_")) return key.split("_").pop();
|
||||
if (key === "about.app_battery_share_value") return `${values.percent}% of device`;
|
||||
return key;
|
||||
};
|
||||
expect(formatAppBatteryUsageLabel({ confidence: "warming_up" }, t)).toBe("warming");
|
||||
expect(
|
||||
formatAppBatteryUsageLabel(
|
||||
{
|
||||
confidence: "estimate",
|
||||
estimated_percent_per_hour: 1.4,
|
||||
intensity: "moderate",
|
||||
},
|
||||
t
|
||||
)
|
||||
).toBe("~1.4%/hr (moderate)");
|
||||
expect(formatAppBatteryShareLabel({ machine_share_percent: 2.5 }, t)).toBe("2.5% of device");
|
||||
expect(appBatteryUsageToneClass({ intensity: "high" })).toContain("amber");
|
||||
});
|
||||
|
||||
describe("getDeviceBatteryStatus probe order and failures", () => {
|
||||
it("prefers android bridge over web battery", async () => {
|
||||
it("prefers android bridge and skips web by default", async () => {
|
||||
window.MeshChatXAndroid = {
|
||||
getBatteryStatus: () => '{"level":77,"charging":true}',
|
||||
};
|
||||
|
|
@ -145,22 +183,28 @@ describe("deviceBattery", () => {
|
|||
expect(navigator.getBattery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back when android returns empty or throws", async () => {
|
||||
it("ignores docker-style web battery unless allowWeb is set", async () => {
|
||||
navigator.getBattery = vi.fn(async () => ({ level: 1, charging: true }));
|
||||
await expect(getDeviceBatteryStatus()).resolves.toBe(null);
|
||||
expect(navigator.getBattery).not.toHaveBeenCalled();
|
||||
|
||||
await expect(getDeviceBatteryStatus({ allowWeb: true })).resolves.toEqual({
|
||||
supported: true,
|
||||
level: 100,
|
||||
charging: true,
|
||||
source: "web",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to web only when allowWeb and android fails", async () => {
|
||||
window.MeshChatXAndroid = {
|
||||
getBatteryStatus: () => {
|
||||
throw new Error("bridge boom");
|
||||
},
|
||||
};
|
||||
navigator.getBattery = vi.fn(async () => ({ level: 0.33, charging: false }));
|
||||
await expect(getDeviceBatteryStatus()).resolves.toEqual({
|
||||
supported: true,
|
||||
level: 33,
|
||||
charging: false,
|
||||
source: "web",
|
||||
});
|
||||
|
||||
window.MeshChatXAndroid = { getBatteryStatus: () => "" };
|
||||
await expect(getDeviceBatteryStatus()).resolves.toEqual({
|
||||
await expect(getDeviceBatteryStatus()).resolves.toBe(null);
|
||||
await expect(getDeviceBatteryStatus({ allowWeb: true })).resolves.toEqual({
|
||||
supported: true,
|
||||
level: 33,
|
||||
charging: false,
|
||||
|
|
@ -186,7 +230,8 @@ describe("deviceBattery", () => {
|
|||
expect(navigator.getBattery).not.toHaveBeenCalled();
|
||||
|
||||
window.electron.getBatteryStatus = vi.fn().mockRejectedValue(new Error("ipc fail"));
|
||||
await expect(getDeviceBatteryStatus()).resolves.toEqual({
|
||||
await expect(getDeviceBatteryStatus()).resolves.toBe(null);
|
||||
await expect(getDeviceBatteryStatus({ allowWeb: true })).resolves.toEqual({
|
||||
supported: true,
|
||||
level: 90,
|
||||
charging: true,
|
||||
|
|
@ -198,10 +243,10 @@ describe("deviceBattery", () => {
|
|||
navigator.getBattery = vi.fn(async () => {
|
||||
throw new Error("denied");
|
||||
});
|
||||
await expect(getDeviceBatteryStatus()).resolves.toBe(null);
|
||||
await expect(getDeviceBatteryStatus({ allowWeb: true })).resolves.toBe(null);
|
||||
|
||||
navigator.getBattery = vi.fn(async () => null);
|
||||
await expect(getDeviceBatteryStatus()).resolves.toBe(null);
|
||||
await expect(getDeviceBatteryStatus({ allowWeb: true })).resolves.toBe(null);
|
||||
});
|
||||
|
||||
it("returns null when no probe is available", async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue