test
This commit is contained in:
@@ -35,9 +35,9 @@ function runCommand(argv) {
|
||||
log(`runCommand: ${argv.join(' ')}`);
|
||||
try {
|
||||
const [ok, stdout, stderr, exitCode] = GLib.spawn_sync(
|
||||
null, // working dir
|
||||
null,
|
||||
argv,
|
||||
null, // inherit env
|
||||
null,
|
||||
GLib.SpawnFlags.SEARCH_PATH,
|
||||
null,
|
||||
);
|
||||
@@ -62,7 +62,7 @@ function isServiceActive() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get interface info for IFACE_NAME.
|
||||
* Get interface addresses for IFACE_NAME.
|
||||
* Returns { found, ipv4, ipv6 } where ipv4/ipv6 are strings or null.
|
||||
*/
|
||||
function getIfaceInfo() {
|
||||
@@ -74,16 +74,82 @@ function getIfaceInfo() {
|
||||
return {found: false, ipv4: null, ipv6: null};
|
||||
}
|
||||
|
||||
const parts = r.stdout.split(/\s+/);
|
||||
const parts = r.stdout.split(/\s+/);
|
||||
// parts[0] = iface, parts[1] = state, parts[2..] = addresses
|
||||
const addrs = parts.slice(2);
|
||||
const ipv4 = addrs.find(a => /^\d+\.\d+\.\d+\.\d+\//.test(a)) ?? null;
|
||||
const ipv6 = addrs.find(a => a.includes(':') && !a.startsWith('fe80')) ?? null;
|
||||
const addrs = parts.slice(2);
|
||||
const ipv4 = addrs.find(a => /^\d+\.\d+\.\d+\.\d+\//.test(a)) ?? null;
|
||||
const ipv6 = addrs.find(a => a.includes(':') && !a.startsWith('fe80')) ?? null;
|
||||
log(`getIfaceInfo: found=true state=${parts[1]} ipv4=${ipv4} ipv6=${ipv6}`);
|
||||
return {found: true, ipv4, ipv6};
|
||||
}
|
||||
|
||||
/** Start or stop the systemd service (requires polkit / passwordless sudo). */
|
||||
/**
|
||||
* Read a single integer from a sysfs file, returning null on failure.
|
||||
*/
|
||||
function readSysfs(path) {
|
||||
try {
|
||||
const f = Gio.File.new_for_path(path);
|
||||
const [ok, contents] = f.load_contents(null);
|
||||
if (!ok) return null;
|
||||
const val = parseInt(new TextDecoder().decode(contents).trim(), 10);
|
||||
return isNaN(val) ? null : val;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get TX/RX byte counters for IFACE_NAME from sysfs.
|
||||
* Returns { rx, tx } in bytes, or null values if unavailable.
|
||||
*/
|
||||
function getIfaceStats() {
|
||||
const base = `/sys/class/net/${IFACE_NAME}/statistics`;
|
||||
const rx = readSysfs(`${base}/rx_bytes`);
|
||||
const tx = readSysfs(`${base}/tx_bytes`);
|
||||
log(`getIfaceStats: rx=${rx} tx=${tx}`);
|
||||
return {rx, tx};
|
||||
}
|
||||
|
||||
/** Format bytes into a human-readable string. */
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === null) return '—';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the service ActiveEnterTimestamp from systemctl show.
|
||||
* Returns a human-readable uptime string, or null.
|
||||
*/
|
||||
function getServiceUptime() {
|
||||
const r = runCommand([
|
||||
'systemctl', 'show', SERVICE_NAME,
|
||||
'--property=ActiveEnterTimestampMonotonic',
|
||||
]);
|
||||
if (!r.success) return null;
|
||||
|
||||
// Output: ActiveEnterTimestampMonotonic=1234567890
|
||||
const match = r.stdout.match(/=(\d+)/);
|
||||
if (!match) return null;
|
||||
|
||||
const startMonoUs = parseInt(match[1], 10);
|
||||
const nowMonoUs = GLib.get_monotonic_time();
|
||||
const elapsedSecs = Math.floor((nowMonoUs - startMonoUs) / 1_000_000);
|
||||
|
||||
if (elapsedSecs < 0) return null;
|
||||
|
||||
const h = Math.floor(elapsedSecs / 3600);
|
||||
const m = Math.floor((elapsedSecs % 3600) / 60);
|
||||
const s = elapsedSecs % 60;
|
||||
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
/** Start or stop the systemd service (requires polkit). */
|
||||
function setServiceActive(enable) {
|
||||
const action = enable ? 'start' : 'stop';
|
||||
log(`setServiceActive: ${action} ${SERVICE_NAME}`);
|
||||
@@ -118,7 +184,7 @@ class NebulaIndicator extends PanelMenu.Button {
|
||||
hbox.add_child(this._label);
|
||||
this.add_child(hbox);
|
||||
|
||||
// ── dropdown menu ──
|
||||
// ── top-level status ──
|
||||
this._statusItem = new PopupMenu.PopupMenuItem('', {reactive: false});
|
||||
this._statusItem.label.set_style('font-weight: bold;');
|
||||
this.menu.addMenuItem(this._statusItem);
|
||||
@@ -131,6 +197,23 @@ class NebulaIndicator extends PanelMenu.Button {
|
||||
|
||||
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
||||
|
||||
// ── "Network" expandable submenu ──
|
||||
this._networkSubmenu = new PopupMenu.PopupSubMenuMenuItem('Network');
|
||||
|
||||
this._uptimeItem = new PopupMenu.PopupMenuItem('', {reactive: false});
|
||||
this._networkSubmenu.menu.addMenuItem(this._uptimeItem);
|
||||
|
||||
this._rxItem = new PopupMenu.PopupMenuItem('', {reactive: false});
|
||||
this._networkSubmenu.menu.addMenuItem(this._rxItem);
|
||||
|
||||
this._txItem = new PopupMenu.PopupMenuItem('', {reactive: false});
|
||||
this._networkSubmenu.menu.addMenuItem(this._txItem);
|
||||
|
||||
this.menu.addMenuItem(this._networkSubmenu);
|
||||
|
||||
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
||||
|
||||
// ── actions ──
|
||||
this._toggleItem = new PopupMenu.PopupMenuItem('');
|
||||
this._toggleItem.connect('activate', () => this._onToggle());
|
||||
this.menu.addMenuItem(this._toggleItem);
|
||||
@@ -159,31 +242,38 @@ class NebulaIndicator extends PanelMenu.Button {
|
||||
log('_refresh: checking service and interface');
|
||||
this._active = isServiceActive();
|
||||
const iface = getIfaceInfo();
|
||||
log(`_refresh: active=${this._active} iface=${JSON.stringify(iface)}`);
|
||||
this._updateUI(iface);
|
||||
const stats = getIfaceStats();
|
||||
const uptime = this._active ? getServiceUptime() : null;
|
||||
log(`_refresh: active=${this._active} iface=${JSON.stringify(iface)} stats=${JSON.stringify(stats)} uptime=${uptime}`);
|
||||
this._updateUI(iface, stats, uptime);
|
||||
}
|
||||
|
||||
_updateUI(iface) {
|
||||
_updateUI(iface, stats, uptime) {
|
||||
log(`_updateUI: active=${this._active}`);
|
||||
if (this._active) {
|
||||
this._icon.icon_name = 'network-vpn-symbolic';
|
||||
this._label.text = 'VPN';
|
||||
this._label.style = 'margin-left: 4px; margin-right: 2px; color: #57e389;'; // green
|
||||
this._statusItem.label.text = `Status: Connected`;
|
||||
this._label.style = 'margin-left: 4px; margin-right: 2px; color: #57e389;';
|
||||
this._statusItem.label.text = 'Status: Connected';
|
||||
this._ipv4Item.label.text = iface.ipv4 ? `IPv4: ${iface.ipv4}` : 'IPv4: —';
|
||||
this._ipv6Item.label.text = iface.ipv6 ? `IPv6: ${iface.ipv6}` : 'IPv6: —';
|
||||
this._ipv4Item.visible = true;
|
||||
this._ipv6Item.visible = true;
|
||||
this._toggleItem.label.text = 'Disconnect';
|
||||
this._networkSubmenu.visible = true;
|
||||
this._uptimeItem.label.text = `Uptime: ${uptime ?? '—'}`;
|
||||
this._rxItem.label.text = `RX: ${formatBytes(stats.rx)}`;
|
||||
this._txItem.label.text = `TX: ${formatBytes(stats.tx)}`;
|
||||
} else {
|
||||
this._icon.icon_name = 'network-vpn-disabled-symbolic';
|
||||
this._label.text = 'VPN';
|
||||
this._label.style = 'margin-left: 4px; margin-right: 2px; color: #c01c28;'; // red
|
||||
this._statusItem.label.text = `Status: Disconnected`;
|
||||
this._label.style = 'margin-left: 4px; margin-right: 2px; color: #c01c28;';
|
||||
this._statusItem.label.text = 'Status: Disconnected';
|
||||
this._ipv4Item.visible = false;
|
||||
this._ipv6Item.visible = false;
|
||||
this._toggleItem.label.text = 'Connect';
|
||||
this._networkSubmenu.visible = false;
|
||||
}
|
||||
// label is always "VPN"
|
||||
this._label.text = 'VPN';
|
||||
}
|
||||
|
||||
_onToggle() {
|
||||
@@ -192,7 +282,7 @@ class NebulaIndicator extends PanelMenu.Button {
|
||||
setServiceActive(target);
|
||||
// Optimistic update while systemd races
|
||||
this._active = target;
|
||||
this._updateUI(getIfaceInfo());
|
||||
this._updateUI(getIfaceInfo(), getIfaceStats(), null);
|
||||
// Re-poll shortly after to confirm real state
|
||||
GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 2, () => {
|
||||
log('_onToggle: confirmation poll');
|
||||
|
||||
Reference in New Issue
Block a user