<?php
	// Last-Updated: 2026-08-06 17:00 EDT
	// Created: 2026-07 (shared helpers for public monitor + tlb-admin)
	// Authors: Paul Aidukas KN2R, Logan Crook K8LRC
	// Testers (Received / users -t -T): Paul Aidukas KN2R, Dave K5NX
	// Talking/Received: all codecs (GSM, uLaw, ADPCM, RTP, SpeakFreely, EchoLink, IRLP)
	// Active talkers sort to top; after unkey keep last-heard time (like uLaw last tx)
	// so stations do not drop to bottom with Never.
	// EchoLink Attributes: "(T) Talking Nailed up GSM" (RTP keeps "(T1u) Talking RTP uLaw").
	// Codec display: SF/IRLP GSM on connect; u/a/7 after first TX (cached while idle).
	// Status headers (Paul KN2R): Node, Attributes, IPAddress, Protocol, Received, Connected.
	// Shared helper functions and panel renderers used by index.php (the
	// public, unauthenticated status monitor) and tlb-admin/index.php (the
	// admin tool, whose access is controlled entirely by Apache Basic Auth on
	// the tlb-admin/ directory - see tlb-admin/.htaccess). Neither of those
	// files needs to know anything about "who is logged in"; this file has no
	// session/auth logic of its own.

	function tlb_valid_id($s) {
		return (bool)preg_match('/^[A-Za-z0-9._*-]+$/', $s);
	}

	function tlb_valid_ip($s) {
		return (bool)filter_var($s, FILTER_VALIDATE_IP);
	}

	function tlb_valid_host($s) {
		$s = (string)$s;
		if ($s === '' || strlen($s) > 255) {
			return false;
		}
		if (tlb_valid_ip($s)) {
			return true;
		}
		return (bool)preg_match('/^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$/', $s);
	}

	function tlb_valid_port($s) {
		$s = (string)$s;
		return (bool)preg_match('/^[0-9]{1,5}$/', $s) && (int)$s >= 1 && (int)$s <= 65535;
	}

	function tlb_valid_cmd($cmd) {
		$cmd = trim((string)$cmd);
		if ($cmd === '' || strlen($cmd) > 200) {
			return false;
		}
		if ($cmd[0] !== '.') {
			return false;
		}
		return (bool)preg_match('/^[.][A-Za-z0-9._* -]+$/', $cmd);
	}

	function tlb_run_cmd($tlbcmdport, $cmd) {
		$output = shell_exec('/usr/local/bin/tlbcmd ' . $tlbcmdport . ' -s ' . escapeshellarg($cmd) . ' 2>&1');
		return trim((string)$output);
	}

	// Run Paul's restart script (kills tlb + matching tlbevent, verifies gone,
	// starts tlb cleanly). Path via $tlbrestartscript in tlb-admin/index.php.
	// Use restart-tlb (NO policy / tlbevent.sh) or restart-tlb-conference
	// (WITH policy / tlbevent-conference.sh). systemd is NOT required.
	function tlb_restart_service($scriptPath = '') {
		$scriptPath = trim((string)$scriptPath);
		if ($scriptPath === '') {
			foreach (array(
				'/home/thelinkbox/scripts/restart-tlb',
				'/home/thelinkbox/scripts/restart-tlb-conference',
				'/root/bin/restart-tlb',
				'/usr/local/bin/restart-tlb',
				'/usr/local/sbin/restart-tlb',
				'/home/tlb/scripts/restart-tlb',
				'/home/tlb/scripts/restart-tlb-conference',
				'/usr/sbin/restart-tlb',
			) as $candidate) {
				if (is_executable($candidate)) {
					$scriptPath = $candidate;
					break;
				}
			}
		}
		if ($scriptPath === '' || $scriptPath[0] !== '/') {
			return array(false, 'Set $tlbrestartscript in tlb-admin/index.php to the full path of restart-tlb or restart-tlb-conference');
		}
		if (strpos($scriptPath, '..') !== false || !preg_match('#^/[A-Za-z0-9._/-]+$#', $scriptPath)) {
			return array(false, 'Invalid restart script path');
		}
		if (!is_file($scriptPath) || !is_executable($scriptPath)) {
			return array(false, 'Restart script not found or not executable: ' . $scriptPath);
		}

		$lines = array();
		$rc = 1;
		// Full path to sudo (Supermon style) — bare "sudo" can fail oddly under Apache.
		exec('/usr/bin/sudo -n ' . escapeshellarg($scriptPath) . ' 2>&1', $lines, $rc);
		$out = trim(implode("\n", $lines));
		@file_put_contents(
			'/tmp/tlb-restart-web.log',
			date('c') . " rc={$rc} path={$scriptPath}\n{$out}\n---\n",
			FILE_APPEND
		);
		if ($rc !== 0) {
			if ($out === '') {
				$out = 'restart-tlb failed (exit ' . $rc
					. '). Ensure www-data has: sudo NOPASSWD: ' . $scriptPath;
			}
			return array(false, $out);
		}
		// One-line flash so it is obvious in the green banner
		return array(true, 'TheLinkBox restarted OK (restart-tlb finished)');
	}

	function tlb_exec_piped($tlbcmdport, $cmd, $pipe) {
		$output = shell_exec('/usr/local/bin/tlbcmd ' . $tlbcmdport . ' -s ' . escapeshellarg($cmd) . ' | ' . $pipe . ' 2>&1');
		return trim((string)$output);
	}

	function exp_tlb_log_defaults($kind) {
		if ($kind === 'messages') {
			return array(
				'/home/tlb/log/messages',
				'/home/thelinkbox/log/messages',
			);
		}
		return array(
			'/home/tlb.log',
			'/home/tlb/tlb.log',
			'/home/tlb/log/tlb.log',
			'/home/thelinkbox/log/tlb.log',
			'/home/thelinkbox/tlb.log',
		);
	}

	function exp_resolve_log_path($configuredPath, $kind) {
		$configuredPath = trim((string)$configuredPath);
		if ($configuredPath !== '') {
			if (is_readable($configuredPath)) {
				return $configuredPath;
			}
			// Common typo: messges → messages (breaks messages panel after update)
			if (strpos($configuredPath, 'messges') !== false) {
				$fixed = str_replace('messges', 'messages', $configuredPath);
				if ($fixed !== $configuredPath && is_readable($fixed)) {
					return $fixed;
				}
			}
		}
		foreach (exp_tlb_log_defaults($kind) as $path) {
			if (is_readable($path)) {
				return $path;
			}
		}
		$defaults = exp_tlb_log_defaults($kind);
		return ($configuredPath !== '') ? $configuredPath : $defaults[0];
	}

	function exp_read_log_file($path, $maxLines = 500, $fromEnd = false) {
		$path = trim((string)$path);
		$maxLines = (int)$maxLines;
		$unlimited = ($maxLines <= 0);
		if ($unlimited) {
			$maxLines = 100000;
		} else {
			$maxLines = max(1, min(5000, $maxLines));
		}
		if ($path === '' || !is_readable($path)) {
			return false;
		}
		if ($fromEnd && !$unlimited) {
			$output = shell_exec('tail -n ' . $maxLines . ' ' . escapeshellarg($path) . ' 2>/dev/null');
			if ($output === null || $output === '') {
				return array();
			}
			$raw = preg_split("/\r\n|\n|\r/", rtrim((string)$output));
		} else {
			$raw = @file($path, FILE_IGNORE_NEW_LINES);
			if ($raw === false) {
				return false;
			}
		}
		$result = array();
		foreach ($raw as $line) {
			$line = rtrim((string)$line);
			if ($line === '') {
				continue;
			}
			$result[] = $line;
			if (!$unlimited && !$fromEnd && count($result) >= $maxLines) {
				break;
			}
			if ($unlimited && count($result) >= $maxLines) {
				break;
			}
		}
		return $result;
	}

	function exp_log_line_count($path) {
		$path = trim((string)$path);
		if ($path === '' || !is_readable($path)) {
			return 0;
		}
		$count = 0;
		$fh = @fopen($path, 'rb');
		if ($fh === false) {
			return 0;
		}
		while (!feof($fh)) {
			$chunk = fread($fh, 8192);
			if ($chunk === false || $chunk === '') {
				break;
			}
			$count += substr_count($chunk, "\n");
		}
		fclose($fh);
		return $count;
	}

	function exp_log_signature($lines) {
		if ($lines === false || empty($lines)) {
			return '';
		}
		return sha1(implode("\n", $lines));
	}

	function exp_render_log_view($title, $configuredPath, $kind, $maxLines = 500) {
		$path = exp_resolve_log_path($configuredPath, $kind);
		$maxLines = (int)$maxLines;
		$unlimited = ($maxLines <= 0);
		$totalLines = exp_log_line_count($path);
		$lines = exp_read_log_file($path, $maxLines, !$unlimited);
		$sig = exp_log_signature($lines);
		$sectionId = ($kind === 'tlb') ? 'tlb-log' : 'tlb-messages';
		$html = '<div class="admin-col log-panel" id="' . $sectionId . '">';
		$html .= '<h4>' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '</h4>';
		$html .= '<p class="hint-text"><code>' . htmlspecialchars($path, ENT_QUOTES, 'UTF-8') . '</code>';
		if ($lines !== false && $totalLines > 0) {
			$shown = count($lines);
			if ($totalLines > $shown) {
				if ($unlimited) {
					$html .= ' &mdash; showing first ' . $shown . ' of ' . $totalLines . ' lines';
				} else {
					$html .= ' &mdash; showing last ' . $shown . ' of ' . $totalLines . ' lines';
				}
			} elseif ($unlimited) {
				$html .= ' &mdash; ' . $shown . ' lines (full file)';
			} else {
				$html .= ' &mdash; ' . $shown . ' lines';
			}
		}
		$html .= '</p>';
		$html .= '<pre class="log-output" data-log-sig="' . htmlspecialchars($sig, ENT_QUOTES, 'UTF-8') . '">';
		if ($lines === false) {
			$html .= '(log file not readable)';
		} elseif (empty($lines)) {
			$html .= '(log file is empty)';
		} else {
			// ENT_SUBSTITUTE: chat/messages logs often contain Latin-1 bytes; plain
			// htmlspecialchars(..., UTF-8) returns "" on invalid UTF-8 (PHP 8.1+).
			$html .= htmlspecialchars(implode("\n", $lines), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
		}
		$html .= '</pre></div>';
		return $html;
	}

	function exp_render_logs_panel($tlblogpath = '', $tlbmessagespath = '', $tlbmessagesfull = false) {
		$messagesMax = $tlbmessagesfull ? 0 : 500;
		return exp_render_log_view('TLB tlb.log', $tlblogpath, 'tlb', 500)
			. exp_render_log_view('TLB messages', $tlbmessagespath, 'messages', $messagesMax);
	}

	function exp_user_signature($userarr) {
		$ids = array();
		foreach ($userarr as $user) {
			$f = preg_split("/[\s,]+/", $user);
			if (!empty($f[1])) {
				$ids[] = $f[1];
			}
		}
		sort($ids);
		return implode('|', $ids);
	}

	function exp_load_ip_maps($tlbcmdport) {
		$iparr = array();
		$protoarr = array();
		$output = tlb_exec_piped($tlbcmdport, 'showip -q', 'tail -n +2');
		$arr = preg_split("/\n/", $output);
		foreach ($arr as $ip) {
			$f = preg_split("/[\s]+/", $ip);
			if (($f[0] ?? '') === '' || !isset($f[1]) || $f[1] === '') {
				continue;
			}
			$iparr[$f[0]] = $f[1];
			switch ($f[2] ?? '') {
				case "S":
					$protoarr[$f[0]] = "SpeakFreely";
					break;
				case "R":
					$protoarr[$f[0]] = "RTP";
					break;
				case "E":
					$protoarr[$f[0]] = "EchoLink";
					break;
				default:
					$protoarr[$f[0]] = "Unknown";
					break;
			}
		}
		return array($iparr, $protoarr);
	}

	function tlb_action_label($action, $node) {
		$labels = array(
			'mute' => 'Muted ' . $node,
			'unmute' => 'Unmuted ' . $node,
			'bump' => 'Bumped ' . $node,
			'disconnect' => 'Disconnected ' . $node,
			'ban' => 'Banned ' . $node,
			'banip' => 'Banned IP ' . $node,
			'unban' => 'Unbanned ' . $node,
		);
		return $labels[$action] ?? 'Action completed';
	}

	function exp_flash_msg($label, $cmd, $result = '') {
		$msg = $label . ' | command: ' . $cmd;
		if ($result !== '') {
			$msg .= ' | ' . exp_format_tlb_result($result);
		}
		return $msg;
	}

	function exp_format_tlb_result($result) {
		$result = trim((string)$result);
		if (preg_match('/^0\s+(.+)$/i', $result, $m)) {
			return $m[1];
		}
		return $result;
	}

	// EchoLink/RTP bumps leave a kicked/inactive row until ConfMemberTimeout
	// (~40s). Track recent bumps so the admin panel can hide that linger.
	function exp_bump_prune() {
		if (empty($_SESSION['exp_bumped'])) {
			return;
		}
		$now = time();
		foreach ($_SESSION['exp_bumped'] as $node => $ts) {
			if ($now - $ts > 90) {
				unset($_SESSION['exp_bumped'][$node]);
			}
		}
	}

	function exp_bump_mark_node($node) {
		if (!isset($_SESSION['exp_bumped'])) {
			$_SESSION['exp_bumped'] = array();
		}
		$_SESSION['exp_bumped'][$node] = time();
		exp_bump_prune();
	}

	function exp_bump_hide_row($node, $attrStr, $nodeProto = '') {
		exp_bump_prune();
		$ts = $_SESSION['exp_bumped'][$node] ?? 0;
		if ($ts === 0) {
			return false;
		}
		if (in_array($nodeProto, array('EchoLink', 'RTP'), true)) {
			return true;
		}
		return (bool)preg_match('/[IKx]/', (string)$attrStr);
	}

	function exp_control_form($action, $node, $label, $confirm = '', $extras = array()) {
		$nodeEsc = htmlspecialchars($node, ENT_QUOTES, 'UTF-8');
		$labelEsc = htmlspecialchars($label, ENT_QUOTES, 'UTF-8');
		$confirmAttr = $confirm !== ''
			? ' onsubmit="return confirm(' . json_encode($confirm) . ');"'
			: '';
		$html = '<form class="ctrl-form" method="post"' . $confirmAttr . '>'
			. '<input type="hidden" name="action" value="' . htmlspecialchars($action, ENT_QUOTES, 'UTF-8') . '">'
			. '<input type="hidden" name="node" value="' . $nodeEsc . '">';
		foreach ($extras as $key => $value) {
			$html .= '<input type="hidden" name="' . htmlspecialchars((string)$key, ENT_QUOTES, 'UTF-8') . '" value="' . htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8') . '">';
		}
		$html .= '<button type="submit" class="ctrl-btn">' . $labelEsc . '</button>'
			. '</form>';
		return $html;
	}

	function exp_render_banned_panel($tlbcmdport) {
		ob_start();
		$banOutput = tlb_run_cmd($tlbcmdport, '.ban list');
		$banLines = preg_split("/\n/", $banOutput);
		$banEntries = array();
		$banStopwords = array(
			'the', 'a', 'an', 'no', 'none', 'not', 'there', 'is', 'are', 'currently',
			'current', 'rogues', 'rogue', 'gallery', 'banned', 'ban', 'list', 'lists',
			'listing', 'station', 'stations', 'empty', 'following', 'contains', 'shows',
			'showing', 'here', 'these', 'those', 'and', 'or', 'of', 'has', 'have',
		);
		foreach ($banLines as $banLine) {
			$banLine = trim($banLine);
			if ($banLine === '' || preg_match('/^(rogues|banned|no stations|list|the|there|no\b|none|current)/i', $banLine)) {
				continue;
			}
			$banParts = preg_split('/[\s,]+/', $banLine);
			$banId = trim($banParts[0]);
			if ($banId === '' || in_array(strtolower($banId), $banStopwords, true)) {
				continue;
			}
			if (tlb_valid_id($banId) || tlb_valid_ip($banId)) {
				$banEntries[] = $banId;
			}
		}
		$banEntries = array_unique($banEntries);

		echo "<h4>Banned Stations</h4>\n";
		if (count($banEntries) === 0) {
			echo "<p><i>No banned stations.</i></p>\n";
		} else {
			echo "<table class='banned'>\n";
			echo "<tr><th>Banned ID</th><th>Action</th></tr>\n";
			foreach ($banEntries as $banId) {
				$banEsc = htmlspecialchars($banId, ENT_QUOTES, 'UTF-8');
				echo "<tr><td>" . $banEsc . "</td><td>";
				echo '<form class="ctrl-form" method="post" onsubmit="return confirm(' . json_encode('Unban ' . $banId . '?') . ');">'
					. '<input type="hidden" name="action" value="unban">'
					. '<input type="hidden" name="target" value="' . $banEsc . '">'
					. '<button type="submit" class="ctrl-btn">Unban</button>'
					. '</form>';
				echo "</td></tr>\n";
			}
			echo "</table>\n";
		}
		return ob_get_clean();
	}

	function exp_render_links_panel($tlbcmdport) {
		ob_start();
		$linkOutput = tlb_run_cmd($tlbcmdport, '.link');
		echo "<h4>Current Links (connections to other conferences/nodes)</h4>\n";
		echo "<pre class='links-output'>" . htmlspecialchars($linkOutput !== '' ? $linkOutput : 'No active links.', ENT_QUOTES, 'UTF-8') . "</pre>\n";
		echo '<form class="ctrl-form" method="post" onsubmit="return confirm(' . json_encode('Unlink ALL connections? This will drop every linked node and conference.') . ');">'
			. '<input type="hidden" name="action" value="unlinkall">'
			. '<button type="submit" class="ctrl-btn">Unlink All</button>'
			. '</form>';
		echo '<form method="post" class="ctrl-form">'
			. '<input type="hidden" name="action" value="unlink">'
			. '<input type="text" name="target" class="cmd-input" style="width:14em;" placeholder="node, stn####, port, or IP" autocomplete="off">'
			. '&nbsp;<button type="submit" class="ctrl-btn">Unlink</button>'
			. '</form>';
		return ob_get_clean();
	}

	// Format TLB "last tx" like Supermon Received column (HHH:MM:SS).
	function exp_format_received_time($raw) {
		$raw = trim((string) $raw);
		if ($raw === '') {
			return 'Never';
		}
		// D/H:MM:SS (rare for last tx; common for connect time)
		if (preg_match('/^(\d+)\/(\d+):(\d{2}):(\d{2})$/', $raw, $m)) {
			$h = ((int) $m[1] * 24) + (int) $m[2];
			return sprintf('%03d:%02d:%02d', $h, (int) $m[3], (int) $m[4]);
		}
		// H:MM:SS or HH:MM:SS
		if (preg_match('/^(\d+):(\d{2}):(\d{2})$/', $raw, $m)) {
			return sprintf('%03d:%02d:%02d', (int) $m[1], (int) $m[2], (int) $m[3]);
		}
		return $raw;
	}

	// Parse TLB time strings to seconds (0:00:49, 002:37:45, 3/1:02:03).
	function exp_parse_time_to_seconds($raw) {
		$raw = trim((string) $raw);
		if ($raw === '') {
			return -1;
		}
		if (preg_match('/^(\d+)\/(\d+):(\d{2}):(\d{2})$/', $raw, $m)) {
			return (((int) $m[1] * 24) + (int) $m[2]) * 3600 + ((int) $m[3] * 60) + (int) $m[4];
		}
		if (preg_match('/^(\d+):(\d{2}):(\d{2})$/', $raw, $m)) {
			return ((int) $m[1] * 3600) + ((int) $m[2] * 60) + (int) $m[3];
		}
		return -1;
	}

	function exp_format_seconds_received($seconds) {
		$seconds = max(0, (int) $seconds);
		return sprintf(
			'%03d:%02d:%02d',
			(int) floor($seconds / 3600),
			(int) floor(($seconds / 60) % 60),
			(int) ($seconds % 60)
		);
	}

	// After disconnect/reconnect, TLB sometimes still reports the old "last tx".
	// That age cannot exceed Connected for this session — treat as Never.
	function exp_sanitize_last_tx_for_connection($lastTx, $connectTime) {
		$lastTxSecs = exp_parse_time_to_seconds($lastTx);
		$connectSecs = exp_parse_time_to_seconds($connectTime);
		if ($lastTxSecs < 0) {
			return '';
		}
		if ($connectSecs >= 0 && $lastTxSecs > ($connectSecs + 2)) {
			return '';
		}
		return trim((string) $lastTx);
	}

	// Codec from TLB attribute flags (conference.c):
	//   u = uLaw, a = ADPCM, 7 = G.726; no letter = GSM until audio arrives.
	// Inbound Speak Freely/IRLP: TLB cannot know codec until first TX (assumes
	// conference CompressionType). We remember the last seen u/a/7 per station
	// so idle IRLP nodes keep showing the correct codec after they have keyed once.
	function exp_sf_codec_cache_path() {
		return '/tmp/tlb-web-sf-codec.cache';
	}

	function exp_sf_codec_cache_load() {
		$path = exp_sf_codec_cache_path();
		if (!is_readable($path)) {
			return array();
		}
		$raw = @file_get_contents($path);
		if ($raw === false || $raw === '') {
			return array();
		}
		$map = json_decode($raw, true);
		return is_array($map) ? $map : array();
	}

	function exp_sf_codec_cache_remember($nodeId, $letter) {
		$nodeId = (string) $nodeId;
		$letter = (string) $letter;
		// u/a/7 = named codecs; g = GSM (TLB shows no letter for GSM)
		if ($nodeId === '' || !preg_match('/^[ua7g]$/', $letter)) {
			return;
		}
		$path = exp_sf_codec_cache_path();
		$fp = @fopen($path, 'c+');
		if ($fp === false) {
			return;
		}
		if (!flock($fp, LOCK_EX)) {
			fclose($fp);
			return;
		}
		rewind($fp);
		$raw = stream_get_contents($fp);
		$map = ($raw !== false && $raw !== '') ? json_decode($raw, true) : array();
		if (!is_array($map)) {
			$map = array();
		}
		if (($map[$nodeId] ?? '') !== $letter) {
			$map[$nodeId] = $letter;
			ftruncate($fp, 0);
			rewind($fp);
			fwrite($fp, json_encode($map));
			fflush($fp);
		}
		flock($fp, LOCK_UN);
		fclose($fp);
	}

	function exp_sf_codec_cache_lookup($nodeId) {
		$map = exp_sf_codec_cache_load();
		$letter = $map[(string) $nodeId] ?? '';
		if ($letter === 'g') {
			return ''; // GSM: no letter in ()
		}
		return preg_match('/^[ua7]$/', (string) $letter) ? $letter : '';
	}

	// Resolve Speak Freely / IRLP codec letter: live TLB flags, else remembered.
	// Inbound SF/IRLP: TLB assumes conference CompressionType (usually GSM) until
	// the first audio packet — so on connect we show GSM; after TX we learn
	// u/a/7 (or confirm GSM) and keep that while idle / across reconnects.
	function exp_sf_codec_letter($attrCodes, $nodeId, $clientVer = '') {
		$attrCodes = (string) $attrCodes;
		if (strpos($attrCodes, 'u') !== false) {
			exp_sf_codec_cache_remember($nodeId, 'u');
			return 'u';
		}
		if (strpos($attrCodes, 'a') !== false) {
			exp_sf_codec_cache_remember($nodeId, 'a');
			return 'a';
		}
		if (strpos($attrCodes, '7') !== false) {
			exp_sf_codec_cache_remember($nodeId, '7');
			return '7';
		}
		// Keyed with no letter = live GSM
		if (strpos($attrCodes, 'T') !== false) {
			exp_sf_codec_cache_remember($nodeId, 'g');
			return '';
		}
		return exp_sf_codec_cache_lookup($nodeId);
	}

	function exp_codec_letter_label($letter) {
		switch ((string) $letter) {
			case 'u':
				return 'uLaw';
			case 'a':
				return 'ADPCM';
			case '7':
				return 'G.726';
			default:
				return 'GSM';
		}
	}

	function exp_proto_defaults_ulaw($nodeProto, $clientVer = '') {
		// Only RTP guesses uLaw when TLB omits the codec letter.
		return ((string) $nodeProto === 'RTP');
	}

	function exp_attr_ensure_codec(array $attrlist, $attrCodes, $nodeProto, $clientVer = '', $nodeId = '') {
		$attrCodes = (string) $attrCodes;
		$nodeProto = (string) $nodeProto;
		if ($nodeProto === 'SpeakFreely') {
			// Live TLB letter or last remembered after first TX (GSM until then)
			$letter = exp_sf_codec_letter($attrCodes, $nodeId, $clientVer);
			$codec = exp_codec_letter_label($letter);
		} elseif (strpos($attrCodes, 'u') !== false) {
			$codec = 'uLaw';
		} elseif (strpos($attrCodes, 'a') !== false) {
			$codec = 'ADPCM';
		} elseif (strpos($attrCodes, '7') !== false) {
			$codec = 'G.726';
		} elseif ($nodeProto === 'EchoLink') {
			if (strpos($attrCodes, 'C') !== false) {
				return $attrlist;
			}
			$codec = 'Nailed up GSM';
		} elseif (exp_proto_defaults_ulaw($nodeProto, $clientVer)) {
			$codec = 'uLaw';
		} else {
			return $attrlist;
		}
		foreach ($attrlist as $label) {
			if (stripos((string) $label, $codec) !== false) {
				return $attrlist;
			}
			if ($codec === 'Nailed up GSM' && stripos((string) $label, 'GSM') !== false) {
				return $attrlist;
			}
		}
		$attrlist[] = $codec;
		return $attrlist;
	}

	// Ensure () codes include protocol + codec letters even when idle (not keyed).
	// Speak Freely/IRLP: (0)/(0u)/(0a)/(07) using live flags or remembered codec.
	function exp_attr_codes_with_codec($attrCodes, $nodeProto, array $attrlist = array(), $clientVer = '', $nodeId = '') {
		$codes = trim((string) $attrCodes);
		$nodeProto = (string) $nodeProto;
		if ($nodeProto === 'EchoLink') {
			return $codes;
		}
		if ($nodeProto === 'RTP' && strpos($codes, '1') === false) {
			$codes .= '1';
		} elseif ($nodeProto === 'SpeakFreely' && strpos($codes, '0') === false) {
			$codes .= '0';
		}
		if (preg_match('/[ua7]/', $codes)) {
			if ($nodeProto === 'SpeakFreely') {
				exp_sf_codec_letter($codes, $nodeId, $clientVer); // refresh cache while live flags present
			}
			return $codes;
		}
		$joined = implode(' ', $attrlist);
		$letter = '';
		if ($nodeProto === 'SpeakFreely') {
			$letter = exp_sf_codec_letter($attrCodes, $nodeId, $clientVer);
		} elseif (stripos($joined, 'uLaw') !== false) {
			$letter = 'u';
		} elseif (stripos($joined, 'ADPCM') !== false) {
			$letter = 'a';
		} elseif (stripos($joined, 'G.726') !== false) {
			$letter = '7';
		} elseif (exp_proto_defaults_ulaw($nodeProto, $clientVer)) {
			$letter = 'u';
		}
		if ($letter !== '' && strpos($codes, $letter) === false) {
			if (preg_match('/^(.*?)([01])(.*)$/', $codes, $m)) {
				$codes = $m[1] . $m[2] . $letter . $m[3];
			} else {
				$codes .= $letter;
			}
		}
		return $codes;
	}

	// Attributes cell text.
	// EchoLink (GSM): "(T) Talking Nailed up GSM" when keyed — not "(T) Nailed up GSM Talking".
	// RTP/uLaw/others: keep "(T1u) Talking RTP uLaw" style; SF/IRLP show GSM/uLaw/ADPCM.
	function exp_format_attr_cell($attrCodes, $attrlist, $nodeProto, $isTalking, $nodeId = '', $clientVer = '') {
		$attrCodes = (string) $attrCodes;
		if (!is_array($attrlist)) {
			$attrlist = array();
		}
		$isTalking = (bool) $isTalking;
		$attrlist = exp_attr_ensure_codec($attrlist, $attrCodes, $nodeProto, $clientVer, $nodeId);

		if ($nodeProto === 'EchoLink') {
			// Conference hubs (C): drop nailed GSM text — e.g. *HAM* → Conference Permanent …
			if (strpos($attrCodes, 'C') !== false) {
				$attrlist = array_values(array_filter($attrlist, function ($label) {
					return stripos((string) $label, 'Nailed up GSM') === false;
				}));
				$attrCodes = str_replace(array('n', 't'), '', $attrCodes);
			}
			$parts = array();
			$hasT = $isTalking || (strpos($attrCodes, 'T') !== false);
			$hasNail = (strpos($attrCodes, 'C') === false) && (
				(strpos($attrCodes, 't') !== false)
				|| (strpos($attrCodes, 'n') !== false)
				|| in_array('Nailed up GSM', $attrlist, true)
			);
			if ($hasT) {
				$parts[] = '(T)';
				$parts[] = 'Talking';
			}
			if ($hasNail) {
				$parts[] = 'Nailed up GSM';
			}
			foreach ($attrlist as $label) {
				if ($label === 'Talking' || $label === 'Nailed up GSM') {
					continue;
				}
				$parts[] = $label;
			}
			return htmlspecialchars(trim(implode(' ', $parts)), ENT_QUOTES, 'UTF-8');
		}

		$displayCodes = exp_attr_codes_with_codec($attrCodes, $nodeProto, $attrlist, $clientVer, $nodeId);
		$codes = htmlspecialchars($displayCodes, ENT_QUOTES, 'UTF-8');
		$labels = htmlspecialchars(trim(implode(' ', $attrlist)), ENT_QUOTES, 'UTF-8');
		if ($codes === '' && $labels === '') {
			return '';
		}
		$out = '(' . $codes . ')';
		if ($labels !== '') {
			$out .= ' ' . $labels;
		}
		return $out;
	}

	// $showControls toggles whether the per-row Mute/Disconnect/Ban/Ban-IP
	// buttons are rendered. exp.php always passes false (public monitor);
	// tlb-admin/index.php always passes true (Apache already gated access).
	function exp_render_live_panel($tlbcmdport, $showControls) {
		$attributes = array(
			'A' => 'Administrator',
			'a' => 'ADPCM',
			'B' => 'theBridge',
			'C' => 'Conference',
			'F' => 'playing File',
			'f' => 'full duplex',
			'I' => 'Inactive',
			'K' => 'Kicked',
			'L' => 'Lurker',
			'M' => 'Muted audio & text',
			'm' => 'muted audio',
			'P' => 'Permanent',
			'R' => 'Receive only',
			'S' => 'Sysop',
			'T' => 'Talking',
			't' => 'Nailed up GSM',
			'n' => 'Nailed up GSM',
			'u' => 'uLaw',
			'x' => 'Inactive',
			'*' => 'Asterisk',
			'0' => 'Speak Freely',
			'1' => 'RTP',
			'7' => 'G.726',
			'!' => 'Old theBridge',
		);

		ob_start();

		$userarr = array();
		$f = array();
		$portarr = array();
		// port -q Rx flag (when TLB provides it) — helps non-GSM / non-EchoLink
		// key-up detection if attribute T or last tx is late/missing.
		$output = tlb_exec_piped($tlbcmdport, 'port -q', 'tail -n +2');
		$arr = preg_split("/\n/", $output);
		foreach ($arr as $port) {
			$line = trim((string) $port);
			if ($line === '' || preg_match('/^(available\s+ports:?|ports?:?)$/i', $line)) {
				continue;
			}
			if (!preg_match('/\bRx\b/i', $line)) {
				continue;
			}
			// "ASL-41001 Rx", "1 ASL-41001 Rx Tx", "  *HAM* Rx", etc.
			if (preg_match('/([A-Za-z0-9*._-]+)/', $line, $nm)) {
				$portarr[$nm[1]] = 'T';
			}
		}

		// -t = print last tx (Received). Needed when conference.c defaults
		//      bDisplayTalkTime=FALSE (Paul KN2R / Dave K5NX).
		// -T = sort by last tx — EchoLink, ULAW, RTP/SF, all keyed stations to top.
		$output = tlb_exec_piped($tlbcmdport, 'users -q -c -t -T -v', 'tail -n +2');
		$userarr = preg_split("/\n/", $output);
		$userSig = exp_user_signature($userarr);
		if ($userSig !== ($_SESSION['exp_users_sig'] ?? '')) {
			$_SESSION['exp_users_sig'] = $userSig;
			list($iparr, $protoarr) = exp_load_ip_maps($tlbcmdport);
			$_SESSION['exp_iparr'] = $iparr;
			$_SESSION['exp_protoarr'] = $protoarr;
		}
		$iparr = $_SESSION['exp_iparr'] ?? array();
		$protoarr = $_SESSION['exp_protoarr'] ?? array();
		if ($userSig !== '' && empty($iparr)) {
			list($iparr, $protoarr) = exp_load_ip_maps($tlbcmdport);
			$_SESSION['exp_iparr'] = $iparr;
			$_SESSION['exp_protoarr'] = $protoarr;
		}

		// Count rows that will actually display (same filters as the loop below).
		$connCount = 0;
		foreach ($userarr as $user) {
			$f = preg_split("/[\s,]+/", $user);
			if (!isset($f[1]) || $f[1] === '') {
				continue;
			}
			$lurker = false;
			$a = preg_split("//", $f[2] ?? " ", -1, PREG_SPLIT_NO_EMPTY);
			foreach ($a as $aa) {
				if ($aa == "L") {
					$lurker = true;
					break;
				}
			}
			if ($lurker) {
				continue;
			}
			$nodeId = $f[1];
			$nodeProto = $protoarr[$nodeId] ?? '';
			$attrsForHide = $f[2] ?? '';
			if ($attrsForHide == "connected:") {
				$attrsForHide = "";
			}
			if (exp_bump_hide_row($nodeId, $attrsForHide, $nodeProto)) {
				continue;
			}
			$connCount++;
		}

		// Conn count for page toolbar (public monitor / tlb-admin); synced by JS on AJAX refresh.
		echo '<span id="conn-count-data" data-conns="' . (int) $connCount . '" hidden></span>';

		echo "<table class='status'>\n";
		// Compact headers + col classes for alignment (Paul KN2R)
		echo "<tr>";
		echo "<th class=\"col-node\">Node</th>";
		echo "<th class=\"col-attr\">Attributes</th>";
		echo "<th class=\"col-ip\">IPAddress</th>";
		echo "<th class=\"col-proto\">Protocol</th>";
		echo "<th class=\"col-rx\" title=\"Time since last transmit; counts up only while keyed\">Received</th>";
		echo "<th class=\"col-conn\">Connected</th>";
		if ($showControls) {
			echo "<th class=\"col-ctrl\">Controls</th>";
		}
		echo "</tr>\n";

		// Build rows first, then sort active talkers to the top. TLB users -T alone
		// often leaves EchoLink/GSM talkers below RTP/uLaw (as in the ASL-40869 / K8LRC case).
		$statusRows = array();
		$rowOrd = 0;
		foreach ($userarr as $user) {
			$attrlist = array();
			$f = preg_split("/[\s,]+/", $user);
			if (!isset($f[1]) || $f[1] === '') {
				continue;
			}
			// Clear connected: before parsing attribute letters (avoids false 't' from the word)
			if (($f[2] ?? '') === 'connected:') {
				$f[2] = '';
				$f[4] = $f[3] ?? '';
			}
			$lurker = false;
			$a = preg_split("//", $f[2] ?? '', -1, PREG_SPLIT_NO_EMPTY);
			foreach ($a as $aa) {
				$attrLabel = $attributes[$aa] ?? '';
				if ($attrLabel !== '') {
					$attrlist[] = $attrLabel;
				}
				if ($aa == "L") {
					$lurker = true;
				}
			}
			if ($lurker) {
				continue;
			}
			// Prefer explicit TLB fields (users -c -T): connected: / last tx:
			$connectTime = $f[4] ?? '';
			if (preg_match('/connected:\s*([^,]+)/i', $user, $mConn)) {
				$connectTime = trim($mConn[1]);
			}
			$lastTx = '';
			if (preg_match('/last\s*tx:\s*([0-9\/:]+)/i', $user, $mTx)) {
				$lastTx = trim($mTx[1]);
			}
			$lastTxRaw = $lastTx;
			// Drop stale last-tx left over from a prior connection of the same callsign.
			$lastTx = exp_sanitize_last_tx_for_connection($lastTx, $connectTime);
			$staleLastTxCleared = ($lastTxRaw !== '' && $lastTx === '');
			$nodeId = $f[1];
			$nodeProto = $protoarr[$nodeId] ?? '';
			$clientVer = '';
			if (preg_match('/ver:\s*(.+)$/i', $user, $mVer)) {
				$clientVer = trim($mVer[1]);
			}
			if (exp_bump_hide_row($nodeId, $f[2] ?? '', $nodeProto)) {
				continue;
			}
			$nodeIdEsc = htmlspecialchars($nodeId, ENT_QUOTES, 'UTF-8');
			if (substr($nodeId, 0, 3) == "stn" && is_numeric(substr($nodeId, 3)) && $nodeProto == "SpeakFreely") {
				$node = '<a href="https://status.irlp.net/index.php?PSTART=11&nodeid=' . substr($nodeId, 3) . '" target="_blank">' . $nodeIdEsc . '</a>';
			} elseif ($nodeProto == "EchoLink") {
				$dash = strpos($nodeId, "-");
				$call = ($dash > 0) ? substr($nodeId, 0, $dash) : $nodeId;
				$node = '<a href="https://www.qrz.com/lookup?mode=callsign&tquery=' . htmlspecialchars($call, ENT_QUOTES, 'UTF-8') . '" target="_blank">' . $nodeIdEsc . '</a>';
			} else {
				$filteredParts = preg_split('/[-_]/', $nodeId, 2);
				$filterednode = $filteredParts[0];
				$node = '<a href="https://www.qrz.com/db/' . htmlspecialchars($filterednode, ENT_QUOTES, 'UTF-8') . '" target="_blank">' . $nodeIdEsc . '</a>';
			}
			$portStatus = $portarr[$nodeId] ?? ($portarr[strtoupper($nodeId)] ?? '');
			$attrs = $f[2] ?? '';
			$portRx = ($portStatus === 'T');
			// Uppercase T = Talking (all codecs). Lowercase t = nailed GSM (codec flag only).
			$isTalkingAttr = (strpos($attrs, 'T') !== false);
			$lastTxSecs = exp_parse_time_to_seconds($lastTx);
			$connectSecs = exp_parse_time_to_seconds($connectTime);
			if (!isset($_SESSION['exp_rx_txstart']) || !is_array($_SESSION['exp_rx_txstart'])) {
				$_SESSION['exp_rx_txstart'] = array();
			}
			if (!isset($_SESSION['exp_rx_was_talking']) || !is_array($_SESSION['exp_rx_was_talking'])) {
				$_SESSION['exp_rx_was_talking'] = array();
			}
			if (!isset($_SESSION['exp_rx_prev_lasttx']) || !is_array($_SESSION['exp_rx_prev_lasttx'])) {
				$_SESSION['exp_rx_prev_lasttx'] = array();
			}
			// Wall-clock of last heard TX — same for GSM / uLaw / RTP / all codecs.
			// Used when TLB omits last tx (EchoLink often becomes Never after unkey).
			if (!isset($_SESSION['exp_rx_last_heard']) || !is_array($_SESSION['exp_rx_last_heard'])) {
				$_SESSION['exp_rx_last_heard'] = array();
			}
			if (!isset($_SESSION['exp_rx_prev_conn']) || !is_array($_SESSION['exp_rx_prev_conn'])) {
				$_SESSION['exp_rx_prev_conn'] = array();
			}
			// Connected time jumped down ⇒ same callsign reconnected; clear RX session
			// (including last-heard) so Received shows Never until they key again.
			$prevConn = $_SESSION['exp_rx_prev_conn'][$nodeId] ?? null;
			if ($connectSecs >= 0) {
				if ($prevConn !== null && $connectSecs + 2 < (int) $prevConn) {
					unset(
						$_SESSION['exp_rx_txstart'][$nodeId],
						$_SESSION['exp_rx_prev_lasttx'][$nodeId],
						$_SESSION['exp_rx_last_heard'][$nodeId]
					);
					$_SESSION['exp_rx_was_talking'][$nodeId] = 0;
				}
				$_SESSION['exp_rx_prev_conn'][$nodeId] = $connectSecs;
			}
			// Stale last-tx was discarded — drop session last-heard too.
			if ($staleLastTxCleared) {
				unset($_SESSION['exp_rx_last_heard'][$nodeId]);
			}
			$rxExtraClass = '';
			$rxDataAttr = '';
			// Talking / Received for GSM, uLaw, ADPCM, RTP, SpeakFreely, EchoLink, IRLP:
			// - When last tx is present: require freshness (<=2s) so sticky T clears after unkey.
			// - When last tx is missing (Never): fall back to T and/or port Rx so non-GSM
			//   / non-EchoLink key-ups still highlight and count Received (not stuck on Never).
			// - For RTP / SpeakFreely / non-EchoLink: last tx near 0 is enough to highlight
			//   (attribute T is often late or missing on those codecs).
			// - After unkey: keep aging Received from TLB last tx OR session last-heard so
			//   the station stays ranked like ASL-40869 uLaw (000:00:35) instead of Never/bottom.
			$unkeyAfterSecs = 2;
			$txRecent = ($lastTxSecs >= 0 && $lastTxSecs <= $unkeyAfterSecs);
			$prevTx = $_SESSION['exp_rx_prev_lasttx'][$nodeId] ?? null;
			$wasTalking = !empty($_SESSION['exp_rx_was_talking'][$nodeId]);
			$keyupEdge = ($prevTx !== null && $prevTx > $unkeyAfterSecs && $txRecent);
			$isEchoLinkNode = ($nodeProto === 'EchoLink');
			$activelyKeyed = false;
			if ($lastTxSecs >= 0) {
				if ($txRecent && ($isTalkingAttr || $portRx || $wasTalking || $keyupEdge)) {
					$activelyKeyed = true;
				} elseif (!$isEchoLinkNode && $txRecent && $lastTxSecs <= 1) {
					// uLaw / ADPCM / RTP / SpeakFreely / IRLP SF: trust fresh last tx
					$activelyKeyed = true;
				}
			} elseif ($isTalkingAttr || $portRx) {
				// No last-tx field from TLB — still show talking for this keyup.
				$activelyKeyed = true;
			}
			if ($lastTxSecs >= 0) {
				$_SESSION['exp_rx_prev_lasttx'][$nodeId] = $lastTxSecs;
			}
			if ($activelyKeyed) {
				if (!$wasTalking || !isset($_SESSION['exp_rx_txstart'][$nodeId])) {
					$_SESSION['exp_rx_txstart'][$nodeId] = time();
				}
				$_SESSION['exp_rx_was_talking'][$nodeId] = 1;
				$_SESSION['exp_rx_last_heard'][$nodeId] = time();
				$txStart = (int) $_SESSION['exp_rx_txstart'][$nodeId];
				$received = exp_format_seconds_received(time() - $txStart);
				$rxExtraClass = ' received-live';
				$rxDataAttr = ' data-tx-start="' . $txStart . '"';
				$sortTxSecs = 0;
			} else {
				unset($_SESSION['exp_rx_txstart'][$nodeId]);
				$_SESSION['exp_rx_was_talking'][$nodeId] = 0;
				// Idle Received — same rules for every codec (uLaw RTP, GSM EchoLink, …):
				// prefer TLB last tx; else age from last time we saw them keyed.
				if ($lastTxSecs >= 0) {
					$received = exp_format_received_time($lastTx);
					$sortTxSecs = $lastTxSecs;
					// Keep session clock aligned with TLB when it provides last tx.
					$_SESSION['exp_rx_last_heard'][$nodeId] = time() - $lastTxSecs;
				} elseif (isset($_SESSION['exp_rx_last_heard'][$nodeId])) {
					$sortTxSecs = max(0, time() - (int) $_SESSION['exp_rx_last_heard'][$nodeId]);
					$received = exp_format_seconds_received($sortTxSecs);
				} else {
					$received = 'Never';
					$sortTxSecs = -1;
				}
			}
			if ($activelyKeyed) {
				if (strpos($attrs, 'T') === false) {
					$f[2] .= 'T';
				}
				if (!in_array('Talking', $attrlist, true)) {
					$attrlist[] = 'Talking';
				}
			}

			$attrCell = exp_format_attr_cell($f[2] ?? '', $attrlist, $nodeProto, $activelyKeyed, $nodeId, $clientVer);

			ob_start();
			if ($activelyKeyed) {
				echo "<tr class='rColor'>";
			} else {
				echo "<tr>";
			}
			echo '<td class="col-node">' . $node . '</td>';
			echo '<td class="col-attr">' . $attrCell . '</td>';
			$nodeIp = $iparr[$nodeId] ?? '';
			echo '<td class="col-ip"><a href="https://search.arin.net/rdap/?query=' . htmlspecialchars($nodeIp, ENT_QUOTES, 'UTF-8') . '" target="_blank">' . htmlspecialchars($nodeIp, ENT_QUOTES, 'UTF-8') . '</a></td>';
			echo '<td class="col-proto">' . htmlspecialchars($protoarr[$nodeId] ?? 'Unknown', ENT_QUOTES, 'UTF-8') . '</td>';
			echo '<td class="col-rx' . $rxExtraClass . '"' . $rxDataAttr . '>' . htmlspecialchars($received, ENT_QUOTES, 'UTF-8') . '</td>';
			echo '<td class="col-conn">' . htmlspecialchars($connectTime, ENT_QUOTES, 'UTF-8') . '</td>';
			if ($showControls) {
				$isMuted = preg_match('/[mM]/', $f[2] ?? '');
				echo '<td class="col-ctrl controls">';
				if ($isMuted) {
					echo exp_control_form('unmute', $nodeId, 'Unmute');
				} else {
					echo exp_control_form('mute', $nodeId, 'Mute');
				}
				echo exp_control_form('bump', $nodeId, 'Bump', 'Bump ' . $nodeId . '?', array('node_proto' => $nodeProto));
				echo exp_control_form('disconnect', $nodeId, 'Disconnect', 'Disconnect ' . $nodeId . '?');
				echo exp_control_form('ban', $nodeId, 'Ban', 'Ban ' . $nodeId . '? They will not be able to reconnect.');
				echo '</td>';
			}
			echo "</tr>\n";

			$statusRows[] = array(
				'keyed' => $activelyKeyed ? 1 : 0,
				'lastTxSecs' => $sortTxSecs,
				'ord' => $rowOrd++,
				'html' => ob_get_clean(),
			);
		}

		usort($statusRows, static function ($a, $b) {
			// Active talkers first (all codecs).
			if ((int) $a['keyed'] !== (int) $b['keyed']) {
				return ((int) $b['keyed']) <=> ((int) $a['keyed']);
			}
			// Then most recently heard (lower seconds) — GSM/EchoLink same as uLaw/RTP.
			// Never / unknown (-1) sorts after anyone who has talked.
			$aTx = ((int) $a['lastTxSecs'] < 0) ? PHP_INT_MAX : (int) $a['lastTxSecs'];
			$bTx = ((int) $b['lastTxSecs'] < 0) ? PHP_INT_MAX : (int) $b['lastTxSecs'];
			if ($aTx !== $bTx) {
				return $aTx <=> $bTx;
			}
			return ((int) $a['ord']) <=> ((int) $b['ord']);
		});

		foreach ($statusRows as $row) {
			echo $row['html'];
		}
		echo "</table>\n";

		echo '<center><h3 style="color:DarkGreen; font-family: New Courier; font-size: 16px;">';
		$myuptime = exec('uptime');
		$mydate = exec("date '+%A, %B %e %Y, %Z: '");
		echo htmlspecialchars($mydate . ' ' . $myuptime, ENT_QUOTES, 'UTF-8');
		echo "</h3></center>\n";

		return ob_get_clean();
	}
