feat(ui): expose full overlay IP on Status page
The /api/v1/admin endpoint only returns the /64 subnet. Users were
copy-pasting the subnet straight into Compose Message and getting
HTTP 422 from the daemon (\"invalid IP address syntax\"). The full
host part (lower 64 bits) is logged once at boot:
INFO mycelium: Node overlay IP: 43d:956e:7877:d933:eecc:b305:21ff:77f9
Capture it from stdout, surface as a new daemonStatus.overlayIp
field, and render it on Status above the subnet card with the hint
\"use this when sending messages\".
The line carries ANSI colour escapes from tracing's compact format,
so push_log strips SGR sequences before scanning. Hand-rolled to
avoid pulling a regex crate.
Also rebuilds the .deb release artifact.
This commit is contained in:
@@ -1 +1 @@
|
|||||||
a5df5c25c2fb13ff7d3486c6214371dac540ccaa70094e02e458bfd0b5e6b367 release/mycellium-ui_0.1.0_amd64.deb
|
acab8758c4ba7f3894e5e9bce28cbd759b8ec7e08a1ee00f54f6fdcac75a4221 release/mycellium-ui_0.1.0_amd64.deb
|
||||||
|
|||||||
Binary file not shown.
@@ -14,11 +14,16 @@ use serde::Serialize;
|
|||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct DaemonStatus {
|
pub struct DaemonStatus {
|
||||||
pub running: bool,
|
pub running: bool,
|
||||||
pub api_url: Option<String>,
|
pub api_url: Option<String>,
|
||||||
pub key_path: Option<String>,
|
pub key_path: Option<String>,
|
||||||
pub config_path: Option<String>,
|
pub config_path: Option<String>,
|
||||||
|
/// Full overlay IPv6 (e.g. `43d:956e:7877:d933:eecc:b305:21ff:77f9`).
|
||||||
|
/// Surfaced separately from `nodeSubnet` so the UI can show users the
|
||||||
|
/// address they should actually use as a message destination.
|
||||||
|
pub overlay_ip: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn snapshot(state: &AppState) -> DaemonStatus {
|
fn snapshot(state: &AppState) -> DaemonStatus {
|
||||||
@@ -28,6 +33,7 @@ fn snapshot(state: &AppState) -> DaemonStatus {
|
|||||||
api_url: sc.client().map(|c| c.base_url().to_string()),
|
api_url: sc.client().map(|c| c.base_url().to_string()),
|
||||||
key_path: sc.key_path().map(|p| p.display().to_string()),
|
key_path: sc.key_path().map(|p| p.display().to_string()),
|
||||||
config_path: sc.config_path().map(|p| p.display().to_string()),
|
config_path: sc.config_path().map(|p| p.display().to_string()),
|
||||||
|
overlay_ip: sc.overlay_ip(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ pub struct SidecarHandle {
|
|||||||
logs: Mutex<VecDeque<String>>,
|
logs: Mutex<VecDeque<String>>,
|
||||||
config_path: Mutex<Option<PathBuf>>,
|
config_path: Mutex<Option<PathBuf>>,
|
||||||
key_path: Mutex<Option<PathBuf>>,
|
key_path: Mutex<Option<PathBuf>>,
|
||||||
|
/// Full overlay IPv6, parsed out of mycelium's `Node overlay IP: ...`
|
||||||
|
/// startup log line. The daemon's HTTP API only exposes the subnet,
|
||||||
|
/// so we extract the host part from the binary's stderr.
|
||||||
|
overlay_ip: Mutex<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SidecarHandle {
|
impl SidecarHandle {
|
||||||
@@ -58,6 +62,7 @@ impl SidecarHandle {
|
|||||||
logs: Mutex::new(VecDeque::with_capacity(LOG_RING_CAPACITY)),
|
logs: Mutex::new(VecDeque::with_capacity(LOG_RING_CAPACITY)),
|
||||||
config_path: Mutex::new(None),
|
config_path: Mutex::new(None),
|
||||||
key_path: Mutex::new(None),
|
key_path: Mutex::new(None),
|
||||||
|
overlay_ip: Mutex::new(None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +89,21 @@ impl SidecarHandle {
|
|||||||
self.config_path.lock().clone()
|
self.config_path.lock().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn overlay_ip(&self) -> Option<String> {
|
||||||
|
self.overlay_ip.lock().clone()
|
||||||
|
}
|
||||||
|
|
||||||
fn push_log(&self, line: String) {
|
fn push_log(&self, line: String) {
|
||||||
|
// mycelium prefixes log lines with ANSI colour escapes; strip them
|
||||||
|
// before scanning for the overlay IP marker so the regex stays
|
||||||
|
// resilient to cosmetic changes in upstream's log format.
|
||||||
|
if self.overlay_ip.lock().is_none() {
|
||||||
|
let stripped = strip_ansi(&line);
|
||||||
|
if let Some(ip) = extract_overlay_ip(&stripped) {
|
||||||
|
tracing::info!(overlay_ip = %ip, "node overlay IP captured");
|
||||||
|
*self.overlay_ip.lock() = Some(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut buf = self.logs.lock();
|
let mut buf = self.logs.lock();
|
||||||
if buf.len() >= LOG_RING_CAPACITY {
|
if buf.len() >= LOG_RING_CAPACITY {
|
||||||
buf.pop_front();
|
buf.pop_front();
|
||||||
@@ -262,6 +281,7 @@ impl SidecarHandle {
|
|||||||
|
|
||||||
fn cleanup(&self) {
|
fn cleanup(&self) {
|
||||||
*self.api_url.lock() = None;
|
*self.api_url.lock() = None;
|
||||||
|
*self.overlay_ip.lock() = None;
|
||||||
let _ = self.child.lock().take();
|
let _ = self.child.lock().take();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +299,48 @@ impl SidecarHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mycelium logs the assigned overlay IPv6 once at startup like:
|
||||||
|
/// `INFO mycelium: Node overlay IP: 43d:956e:7877:d933:eecc:b305:21ff:77f9`
|
||||||
|
/// We don't pull a regex crate just for this — a hand-rolled parser is
|
||||||
|
/// resilient enough.
|
||||||
|
fn extract_overlay_ip(line: &str) -> Option<String> {
|
||||||
|
const NEEDLE: &str = "Node overlay IP:";
|
||||||
|
let idx = line.find(NEEDLE)?;
|
||||||
|
let rest = line[idx + NEEDLE.len()..].trim();
|
||||||
|
// The IP is the first whitespace-bounded token; rejection criteria
|
||||||
|
// (must contain `:`, must not contain `/`) keep us from accidentally
|
||||||
|
// capturing the subnet.
|
||||||
|
let token = rest.split_whitespace().next()?;
|
||||||
|
if !token.contains(':') || token.contains('/') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(token.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip ANSI SGR escape sequences (`ESC [ ... m`) from a log line so the
|
||||||
|
/// overlay-IP scanner doesn't choke on tracing's coloured output.
|
||||||
|
fn strip_ansi(s: &str) -> String {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
|
||||||
|
// Skip until 'm' or any letter terminator.
|
||||||
|
i += 2;
|
||||||
|
while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if i < bytes.len() {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push(bytes[i] as char);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn pick_port() -> AppResult<u16> {
|
fn pick_port() -> AppResult<u16> {
|
||||||
portpicker::pick_unused_port().ok_or_else(|| AppError::Other("no free port available".into()))
|
portpicker::pick_unused_port().ok_or_else(|| AppError::Other("no free port available".into()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface DaemonStatus {
|
|||||||
apiUrl: string | null;
|
apiUrl: string | null;
|
||||||
keyPath: string | null;
|
keyPath: string | null;
|
||||||
configPath: string | null;
|
configPath: string | null;
|
||||||
|
/** Full overlay IPv6 (e.g. `43d:956e:7877:d933:eecc:b305:21ff:77f9`). */
|
||||||
|
overlayIp: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeInfo {
|
export interface NodeInfo {
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ export const useNodeStore = defineStore("node", () => {
|
|||||||
exitedUnlisten = await on<number>(Events.SidecarExited, (e) => {
|
exitedUnlisten = await on<number>(Events.SidecarExited, (e) => {
|
||||||
error.value = `daemon exited (code ${e.payload})`;
|
error.value = `daemon exited (code ${e.payload})`;
|
||||||
phase.value = "error";
|
phase.value = "error";
|
||||||
status.value = { running: false, apiUrl: null, keyPath: null, configPath: null };
|
status.value = {
|
||||||
|
running: false,
|
||||||
|
apiUrl: null,
|
||||||
|
keyPath: null,
|
||||||
|
configPath: null,
|
||||||
|
overlayIp: null,
|
||||||
|
};
|
||||||
info.value = null;
|
info.value = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ async function copy(field: string, value: string) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="info" class="max-w-3xl space-y-4">
|
<div v-if="info" class="max-w-3xl space-y-4">
|
||||||
|
<InfoCard
|
||||||
|
v-if="status?.overlayIp"
|
||||||
|
label="Overlay IP — use this when sending messages"
|
||||||
|
:value="status.overlayIp"
|
||||||
|
:copied="copiedField === 'ip'"
|
||||||
|
@copy="copy('ip', status.overlayIp!)"
|
||||||
|
/>
|
||||||
<InfoCard
|
<InfoCard
|
||||||
label="Overlay subnet"
|
label="Overlay subnet"
|
||||||
:value="info.nodeSubnet"
|
:value="info.nodeSubnet"
|
||||||
|
|||||||
Reference in New Issue
Block a user