diff --git a/Layout/default/VueViews/Vue3.php b/Layout/default/VueViews/Vue3.php new file mode 100644 index 000000000..e21333939 --- /dev/null +++ b/Layout/default/VueViews/Vue3.php @@ -0,0 +1,133 @@ + + +
+ <> +> +
+ + + + + + + + diff --git a/Layout/default/vueHeader3.php b/Layout/default/vueHeader3.php new file mode 100644 index 000000000..f4a126f50 --- /dev/null +++ b/Layout/default/vueHeader3.php @@ -0,0 +1,83 @@ + + + + + <?= MFAPPNAME_FULL ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
diff --git a/application/Radius/RadiusController.php b/application/Radius/RadiusController.php index 12b12071a..1027fd221 100644 --- a/application/Radius/RadiusController.php +++ b/application/Radius/RadiusController.php @@ -16,12 +16,12 @@ class RadiusController extends mfBaseController { protected function indexAction() { $this->layout()->set('additionalJS', ["plugins/chart.js/chart.4.4.6.js", "plugins/chart.js/chartjs-adapter-moment.min.js"]); - + $allowedAcsUserIds = [9, 13, 25, 65, 135, 145, 178]; $acsEnabled = in_array($this->me->id, $allowedAcsUserIds); - Helper::renderVue($this, $this->mod, "Radius", [ - 'CAN_BILLING' => $this->me->can("Billing"), + Helper::renderVue3($this, $this->mod, "Radius", [ + 'CAN_BILLING' => $this->me->can("Billing"), 'HIDE_PAGE_TITLE' => true, 'USER_ID' => $this->me->id, 'ACS_ENABLED' => $acsEnabled @@ -29,6 +29,7 @@ class RadiusController extends mfBaseController { } protected function proxyUnsecureHTTPRequestToRadiusAction() { + $this->log->debug("proxyUnsecureHTTPRequestToRadiusAction", $_GET); $url = "http://radius.xinon.at/api.php?" . http_build_query($_GET); $url = str_replace("proxyUnsecureHTTPRequestToRadius", "", $url); $opts = [ @@ -44,21 +45,29 @@ class RadiusController extends mfBaseController { die(); } - /** - * Run speedtest via ACS proxy - */ protected function genieacsRunSpeedtestAction() { try { $input = json_decode(file_get_contents('php://input'), true); - $ip = $input['ip'] ?? null; + $deviceId = $input['deviceId'] ?? null; + $this->log->debug("genieacsRunSpeedtestAction", ['deviceId' => $deviceId]); - if (!$ip) { - self::sendError("IP address is required"); - } + if (!$deviceId) self::sendError("Device ID is required"); + + $acs = $this->getGenieACS(); + + $acs->setParameterValues($deviceId, [ + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Start' => 1, + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.StartBidirect' => 1, + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.WANAccess' => true + ]); + + $device = $acs->getDevice($deviceId); + $ip = GenieACS::getExternalIP($device); + + if (!$ip) self::sendError("Could not determine device IP"); $url = "http://acs.xinon.at:5000/run-speedtest"; $apiKey = "2H9zWrgxPEJL9MZ1yTGtWh16cPCu0AsQ"; - $data = json_encode(['ip' => $ip]); $opts = [ @@ -74,332 +83,59 @@ class RadiusController extends mfBaseController { $context = stream_context_create($opts); $response = file_get_contents($url, false, $context); - if ($response === false) { - self::sendError("Failed to connect to speedtest server"); - } + if ($response === false) self::sendError("Failed to connect to speedtest server"); header("Content-Type: application/json"); echo $response; die(); } catch (Exception $e) { - error_log("GenieACS runSpeedtest error: " . $e->getMessage()); + $this->log->debug("Speedtest Error", ['error' => $e->getMessage()]); self::sendError("Error running speedtest: " . $e->getMessage()); } } - protected function sendCustomerEmailAction() { - $input = json_decode(file_get_contents('php://input'), true); - if (!$input || !isset($input['username'], $input['year'], $input['month'], $input['monthlySummary'], $input['monthlyDetails'], $input['recipient'])) - self::sendError("Ungültige oder unvollständige Eingabedaten."); - - $username = $input['username']; - $year = $input['year']; - $month = $input['month']; - $monthlySummary = $input['monthlySummary']; - $monthlyDetails = $input['monthlyDetails']; - $chartImage = $input['chartImage'] ?? ''; - $recipient = $input['recipient']; - - if (!filter_var($recipient, FILTER_VALIDATE_EMAIL)) - self::sendError("Ungültige E-Mail-Adresse des Empfängers."); - - $monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; - $monthName = $monthNames[$month - 1] ?? $month; - $subject = "Ihre Transfer-Statistik für {$monthName} {$year}"; - - // --- Helper Functions --- - $formatBytes = function($bytes, $precision = 2) { - $units = ['B', 'KB', 'MB', 'GB', 'TB']; - $bytes = max($bytes, 0); - $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); - $pow = min($pow, count($units) - 1); - $bytes /= (1 << (10 * $pow)); - return round($bytes, $precision) . ' ' . $units[$pow]; - }; - - $formatDuration = function($seconds) { - $seconds = max(0, intval($seconds)); - if ($seconds === 0) return '00:00:00'; - $days = floor($seconds / 86400); - if ($days > 0) { - $remainder = $seconds % 86400; - $dayString = $days . ' ' . ($days == 1 ? 'Tag' : 'Tage'); - if ($remainder === 0) return $dayString; - return $dayString . ', ' . sprintf('%02d:%02d:%02d', floor($remainder / 3600), floor(($remainder % 3600) / 60), $remainder % 60); - } - return sprintf('%02d:%02d:%02d', floor($seconds / 3600), floor(($seconds % 3600) / 60), $seconds % 60); - }; - - // --- Data Preparation --- - $customerNumber = preg_replace('/[^0-9]/', '', $username) ?: 'Kunde'; - $logoToolPath = LIBDIR . '/../public/assets/images/the-tool-logo.png'; - $logoXinonPath = LIBDIR . '/../public/assets/images/xinon-full.png'; - $logoToolTag = file_exists($logoToolPath) ? 'TheTOOL Logo' : ''; - $logoXinonTag = file_exists($logoXinonPath) ? 'XINON Logo' : ''; - $currentYear = date("Y"); - $xinonBlue = '#005384'; - - // Monthly summary values - $monthlyTotal = $formatBytes($monthlySummary['grandTotalBytes']); - $monthlyDuration = $formatDuration($monthlySummary['totalDurationSeconds']); - $monthlyUpload = $formatBytes($monthlySummary['totalUploadBytes']); - $monthlyDownload = $formatBytes($monthlySummary['totalDownloadBytes']); - - // --- Daily Details Table Generation --- - $dailyDetailsTable = ''; - foreach ($monthlyDetails as $detail) { - $date = date("d.m.Y", strtotime($detail['startTime'])); - $dailyDetailsTable .= " - - {$date} - {$formatDuration($detail['durationSeconds'])} - {$formatBytes($detail['uploadBytes'])} - {$formatBytes($detail['downloadBytes'])} - {$formatBytes($detail['totalBytes'])} - "; - } - - // --- Base64 Encoded Icons (shortened as requested) --- - $icons = [ - 'total' => 'data:image/png;base64,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', - 'download' => 'data:image/png;base64,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', - 'upload' => 'data:image/png;base64,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', - 'duration' => 'data:image/png;base64,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', - ]; - - // --- Bulletproof HTML Email Template --- - $html = << - - - - - - {$subject} - - - - - - - -
- - - - - - - - - - -
- - - - - -
{$logoToolTag}{$logoXinonTag}
-
-

Ihre Transfer-Statistik

-

für {$monthName} {$year}

-

Sehr geehrter Kunde,
anbei finden Sie eine Übersicht Ihrer Netzwerk-Nutzung.

- - - - - - - - - - - -
- - - - -
Gesamt
Monat gesamt
{$monthlyTotal}
-
- - - - -
Download
Download
{$monthlyDownload}
-
 
- - - - -
Upload
Upload
{$monthlyUpload}
-
- - - - -
Dauer
Dauer
{$monthlyDuration}
-
- -
Transfer Statistik Chart
- -

Tagesübersicht

- - - - - - - - - - - - {$dailyDetailsTable} - -
DatumDauerUploadDownloadGesamt
-

Bei Fragen zu Ihrer Statistik stehen wir Ihnen gerne zur Verfügung.

-
-

XINON GmbH

-

Fladnitz im Raabtal 150 | A-8322 Studenzen

-

- +43 3115 40800 | - office@xinon.at -

-

© {$currentYear} XINON GmbH | Impressum

-
-
- - -HTML; - $altBody = "Ihre Transfer-Statistik für {$monthName} {$year}\n\n" . - "Sehr geehrter Kunde,\n\n" . - "anbei finden Sie eine Übersicht Ihrer Netzwerk-Nutzung für {$monthName} {$year}.\n\n" . - "Monatszusammenfassung:\n" . - "Gesamt: {$monthlyTotal}\n" . - "Download: {$monthlyDownload}\n" . - "Upload: {$monthlyUpload}\n" . - "Dauer: {$monthlyDuration}\n\n" . - "Eine detaillierte tägliche Aufstellung finden Sie in der HTML-Version dieser E-Mail.\n\n" . - "Bei Fragen zu Ihrer Statistik stehen wir Ihnen gerne zur Verfügung.\n\n" . - "© {$currentYear} XINON GmbH | Impressum: https://xinon.at/impressum/"; - - $mail = new PHPMailer(true); + protected function genieacsGetSpeedtestResultAction() { try { - $mail->isSMTP(); - $mail->Host = TT_PIPEWORK_SMTP_HOST; - $mail->SMTPAuth = true; - $mail->Username = TT_PIPEWORK_SMTP_USER; - $mail->Password = TT_PIPEWORK_SMTP_PASS; - $mail->CharSet = PHPMailer::CHARSET_UTF8; - $mail->Encoding = 'base64'; - $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; - $mail->Port = 587; + $input = json_decode(file_get_contents('php://input'), true); + $deviceId = $input['deviceId'] ?? null; + $this->log->debug("genieacsGetSpeedtestResultAction", ['deviceId' => $deviceId]); - if (file_exists($logoToolPath)) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool'); - if (file_exists($logoXinonPath)) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon'); + if (!$deviceId) self::sendError("Device ID is required"); - if (!empty($chartImage) && ($imageData = base64_decode(preg_replace('#^data:image/\w+;base64,#i', '', $chartImage)))) { - $mail->addStringEmbeddedImage($imageData, 'chart_image', 'chart.png', 'base64', 'image/png'); - } + $acs = $this->getGenieACS(); + $value = $acs->getSpeedtestResult($deviceId); - $mail->setFrom('thetool@xinon.at', 'TheTOOL by XINON'); - $mail->addReplyTo('office@xinon.at', 'XINON Office'); - $mail->addAddress($recipient, $customerNumber); - $mail->isHTML(true); - $mail->Subject = $subject; - $mail->Body = $html; - $mail->AltBody = $altBody; - - $mail->send(); - self::returnJson(['success' => true, 'message' => 'Transfer-Statistik E-Mail wurde erfolgreich gesendet.']); + self::returnJson(['success' => true, 'value' => $value]); } catch (Exception $e) { - error_log("Mailer Error in sendCustomerEmailAction for username {$username}: " . $mail->ErrorInfo); - self::sendError("E-Mail konnte nicht gesendet werden. Bitte kontaktieren Sie den Support."); + $this->log->debug("Speedtest Result Error", ['error' => $e->getMessage()]); + self::sendError($e->getMessage()); } } - /** - * Get GenieACS instance - * @return GenieACS - */ private function getGenieACS() { $host = defined('GENIEACS_HOST') ? GENIEACS_HOST : 'http://acs.xinon.at:3000'; $username = defined('GENIEACS_USERNAME') ? GENIEACS_USERNAME : 'admin'; $password = defined('GENIEACS_PASSWORD') ? GENIEACS_PASSWORD : 'savemanfb545aw'; - return new GenieACS($host, $username, $password); } - /** - * Check if GenieACS task response indicates success - * @param mixed $result - * @return bool - */ - private static function isGenieAcsTaskSuccess($result) { - // Null or empty array can indicate success (204 No Content) - if ($result === null || (is_array($result) && empty($result))) { - return true; - } - - // Has explicit success flag - if (isset($result['success']) && $result['success']) { - return true; - } - - // Array of tasks with _id (task created successfully) - if (is_array($result)) { - // Check if it's an array of task objects - foreach ($result as $item) { - if (is_array($item) && isset($item['_id'])) { - return true; // Task was created - } - } - } - - return false; - } - - /** - * Get devices from GenieACS and match by IP - */ protected function genieacsGetDeviceByIpAction() { try { $ip = $_GET['ip'] ?? null; - if (!$ip) { - self::sendError("IP address is required"); - } + $this->log->debug("genieacsGetDeviceByIpAction", ['ip' => $ip]); + if (!$ip) self::sendError("IP address is required"); $acs = $this->getGenieACS(); $devices = $acs->getDevices(); - if (!$devices || !is_array($devices)) { + if (!$devices) { self::returnJson(['success' => false, 'message' => 'No devices found']); return; } - // Find device by matching external IP $matchedDevice = null; foreach ($devices as $device) { - $externalIp = GenieACS::getExternalIP($device); - if ($externalIp === $ip) { + if (GenieACS::getExternalIP($device) === $ip) { $matchedDevice = $device; break; } @@ -410,213 +146,312 @@ HTML; return; } - $deviceId = GenieACS::getDeviceId($matchedDevice); - $deviceInfo = GenieACS::getDeviceInfo($matchedDevice); - $managementIp = GenieACS::getManagementIP($matchedDevice); - self::returnJson([ 'success' => true, - 'deviceId' => $deviceId, - 'deviceInfo' => $deviceInfo, + 'deviceId' => GenieACS::getDeviceId($matchedDevice), + 'deviceInfo' => GenieACS::getDeviceInfo($matchedDevice), 'ip' => $ip, - 'managementIp' => $managementIp + 'managementIp' => GenieACS::getManagementIP($matchedDevice) ]); } catch (Exception $e) { - error_log("GenieACS getDeviceByIp error: " . $e->getMessage()); + $this->log->debug("GetDeviceByIp Error", ['error' => $e->getMessage()]); self::sendError("Error fetching device: " . $e->getMessage()); } } - /** - * Reboot device via GenieACS - */ protected function genieacsRebootDeviceAction() { try { $input = json_decode(file_get_contents('php://input'), true); $deviceId = $input['deviceId'] ?? null; + $this->log->debug("genieacsRebootDeviceAction", ['deviceId' => $deviceId]); - if (!$deviceId) { - self::sendError("Device ID is required"); - } - - error_log("GenieACS reboot request for device: " . $deviceId); + if (!$deviceId) self::sendError("Device ID is required"); $acs = $this->getGenieACS(); - $result = $acs->rebootDevice($deviceId); + $acs->rebootDevice($deviceId); - error_log("GenieACS reboot result: " . json_encode($result)); - - if (self::isGenieAcsTaskSuccess($result)) { - self::returnJson(['success' => true, 'message' => 'Reboot task created', 'result' => $result]); - } else { - self::returnJson(['success' => false, 'message' => 'Failed to create reboot task', 'result' => $result]); - } + self::returnJson(['success' => true, 'message' => 'Reboot task created']); } catch (Exception $e) { - error_log("GenieACS rebootDevice error: " . $e->getMessage()); + $this->log->debug("Reboot Error", ['error' => $e->getMessage()]); self::sendError("Error rebooting device: " . $e->getMessage()); } } - /** - * Refresh device information - */ - protected function genieacsRefreshDeviceAction() { - try { - $input = json_decode(file_get_contents('php://input'), true); - $deviceId = $input['deviceId'] ?? null; - - if (!$deviceId) { - self::sendError("Device ID is required"); - } - - $acs = $this->getGenieACS(); - $result = $acs->refreshDevice($deviceId); - - if (self::isGenieAcsTaskSuccess($result)) { - self::returnJson(['success' => true, 'message' => 'Refresh task created', 'result' => $result]); - } else { - self::returnJson(['success' => false, 'message' => 'Failed to create refresh task', 'result' => $result]); - } - } catch (Exception $e) { - error_log("GenieACS refreshDevice error: " . $e->getMessage()); - self::sendError("Error refreshing device: " . $e->getMessage()); - } - } - - /** - * Get device parameters - */ - protected function genieacsGetParametersAction() { - try { - $input = json_decode(file_get_contents('php://input'), true); - $deviceId = $input['deviceId'] ?? null; - $parameters = $input['parameters'] ?? []; - - if (!$deviceId || empty($parameters)) { - self::sendError("Device ID and parameters are required"); - } - - $acs = $this->getGenieACS(); - $result = $acs->getParameterValues($deviceId, $parameters); - - // Check if result is a successful task response - if (self::isGenieAcsTaskSuccess($result)) { - self::returnJson(['success' => true, 'message' => 'Get parameters task created', 'result' => $result]); - } else { - self::returnJson(['success' => false, 'message' => 'Failed to create get parameters task', 'result' => $result]); - } - } catch (Exception $e) { - error_log("GenieACS getParameters error: " . $e->getMessage()); - self::sendError("Error getting parameters: " . $e->getMessage()); - } - } - - /** - * Set device parameters - */ - protected function genieacsSetParametersAction() { - try { - $input = json_decode(file_get_contents('php://input'), true); - $deviceId = $input['deviceId'] ?? null; - $parameters = $input['parameters'] ?? []; - - if (!$deviceId || empty($parameters)) { - self::sendError("Device ID and parameters are required"); - } - - $acs = $this->getGenieACS(); - $result = $acs->setParameterValues($deviceId, $parameters); - - if (self::isGenieAcsTaskSuccess($result)) { - self::returnJson(['success' => true, 'message' => 'Set parameters task created', 'result' => $result]); - } else { - self::returnJson(['success' => false, 'message' => 'Failed to create set parameters task', 'result' => $result]); - } - } catch (Exception $e) { - error_log("GenieACS setParameters error: " . $e->getMessage()); - self::sendError("Error setting parameters: " . $e->getMessage()); - } - } - - /** - * Get full device information - */ protected function genieacsGetDeviceInfoAction() { try { $deviceId = $_GET['deviceId'] ?? null; + $this->log->debug("genieacsGetDeviceInfoAction", ['deviceId' => $deviceId]); - if (!$deviceId) { - self::sendError("Device ID is required"); - } + if (!$deviceId) self::sendError("Device ID is required"); $acs = $this->getGenieACS(); $device = $acs->getDevice($deviceId); - if (!$device) { - self::sendError("Device not found"); - } - - $deviceInfo = GenieACS::getDeviceInfo($device); - $externalIp = GenieACS::getExternalIP($device); - $macAddress = GenieACS::getMacAddress($device); + if (!$device) self::sendError("Device not found"); self::returnJson([ 'success' => true, - 'deviceInfo' => $deviceInfo, - 'externalIp' => $externalIp, - 'macAddress' => $macAddress, + 'deviceInfo' => GenieACS::getDeviceInfo($device), + 'externalIp' => GenieACS::getExternalIP($device), + 'macAddress' => GenieACS::getMacAddress($device), 'fullData' => $device ]); } catch (Exception $e) { - error_log("GenieACS getDeviceInfo error: " . $e->getMessage()); + $this->log->debug("GetDeviceInfo Error", ['error' => $e->getMessage()]); self::sendError("Error getting device info: " . $e->getMessage()); } } - /** - * Ping an IP address via GenieACS - */ protected function genieacsPingAction() { try { $ip = $_GET['ip'] ?? null; + $this->log->debug("genieacsPingAction", ['ip' => $ip]); - if (!$ip) { - self::sendError("IP address is required"); - } + if (!$ip) self::sendError("IP address is required"); $acs = $this->getGenieACS(); $result = $acs->ping($ip); self::returnJson(['success' => true, 'result' => $result]); } catch (Exception $e) { - error_log("GenieACS ping error: " . $e->getMessage()); + $this->log->debug("Ping Error", ['error' => $e->getMessage()]); self::sendError("Error pinging: " . $e->getMessage()); } } - - /** - * Factory reset device via GenieACS - */ - protected function genieacsFactoryResetAction() { + + protected function genieacsRemoteAccessAction() { try { $input = json_decode(file_get_contents('php://input'), true); $deviceId = $input['deviceId'] ?? null; + $this->log->debug("genieacsRemoteAccessAction", ['deviceId' => $deviceId]); - if (!$deviceId) { - self::sendError("Device ID is required"); - } - + if (!$deviceId) self::sendError("Device ID is required"); + $acs = $this->getGenieACS(); - $result = $acs->factoryResetDevice($deviceId); - - if (self::isGenieAcsTaskSuccess($result)) { - self::returnJson(['success' => true, 'message' => 'Factory reset task created', 'result' => $result]); + $result = $acs->createRemoteUser($deviceId); + + if ($result) { + self::returnJson(['success' => true] + $result); } else { - self::returnJson(['success' => false, 'message' => 'Failed to create factory reset task', 'result' => $result]); + self::sendError("Could not retrieve TR069 username from device"); } } catch (Exception $e) { - error_log("GenieACS factoryReset error: " . $e->getMessage()); - self::sendError("Error factory resetting device: " . $e->getMessage()); + $this->log->debug("Remote Access Error", ['error' => $e->getMessage()]); + self::sendError("Error configuring remote access: " . $e->getMessage()); } } -} + + protected function genieacsNetworkStructureAction() { + try { + $input = json_decode(file_get_contents('php://input'), true); + $deviceId = $input['deviceId'] ?? null; + $this->log->debug("genieacsNetworkStructureAction", ['deviceId' => $deviceId]); + + if (!$deviceId) self::sendError("Device ID is required"); + + $acs = $this->getGenieACS(); + $creds = $acs->createRemoteUser($deviceId); + + if (!$creds) self::sendError("Could not obtain credentials for FritzBox"); + + $url = "http://acs.xinon.at:5000/read-fritz"; + $apiKey = "2H9zWrgxPEJL9MZ1yTGtWh16cPCu0AsQ"; + + $data = json_encode([ + 'fritz_ip' => $creds['ip'], + 'fritz_port' => "9090", + 'fritz_user' => $creds['username'], + 'fritz_pass' => $creds['password'], + 'page' => 'netDev' + ]); + + $opts = [ + "http" => [ + "method" => "POST", + "header" => "Content-Type: application/json\r\n" . + "X-API-Key: " . $apiKey . "\r\n" . + "Content-Length: " . strlen($data) . "\r\n", + "content" => $data, + "timeout" => 60 + ] + ]; + + $context = stream_context_create($opts); + $response = file_get_contents($url, false, $context); + + if ($response) { + $json = json_decode($response, true); + // Check deeper structure: data -> data -> fbox/active + if ($json && isset($json['data']['data'])) { + $this->ensureMacDb(); + + $raw = $json['data']['data']; + $fbox = $raw['fbox'][0] ?? null; + $active = $raw['active'] ?? []; + + if (!$fbox) { + self::returnJson(['root' => null]); + return; + } + + // 2. Enrich active devices with Vendor and Initialize Children + foreach ($active as &$dev) { + $dev['children'] = []; + if (isset($dev['mac']) && $dev['mac']) { + $dev['vendor'] = $this->getVendor($dev['mac']); + } + } + unset($dev); + + // 3. Prepare Root + if (isset($fbox['mac']) && $fbox['mac']) { + $fbox['vendor'] = $this->getVendor($fbox['mac']); + } + $fbox['children'] = []; + $fbox['model'] = 'fbox'; + $fbox['name'] = $fbox['name'] ?? 'FRITZ!Box'; + $fboxIp = $fbox['ipv4']['ip'] ?? '192.168.178.1'; + + // 4. Map Active Devices by IP + $deviceMap = []; + foreach ($active as &$dev) { + if (isset($dev['ipv4']['ip']) && $dev['ipv4']['ip']) { + $deviceMap[$dev['ipv4']['ip']] = &$dev; + } + } + unset($dev); + + // 5. Build Tree + foreach ($active as &$dev) { + $parentIp = null; + // Attempt to extract IP from parent URL + if (!empty($dev['parent']['url'])) { + if (preg_match('/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/', urldecode($dev['parent']['url']), $matches)) { + $parentIp = $matches[1]; + } + } elseif (!empty($dev['parent']['name']) && $dev['parent']['name'] === 'fritz.repeater') { + // Fallback: if parent name is generic repeater but no URL/IP, strictly it's a child of root? + // But usually repeaters have IPs. If no IP found, attach to root. + } + + // Attach to parent if found, otherwise Root + if ($parentIp && isset($deviceMap[$parentIp]) && $parentIp !== $dev['ipv4']['ip']) { + $deviceMap[$parentIp]['children'][] = &$dev; + } else { + // Check if parent is Root (via IP or name) + // If parent IP is Root IP, or no parent found -> Root + $fbox['children'][] = &$dev; + } + } + unset($dev); + + // 6. Sort Function (Recursive) + $sortChildren = function(&$node) use (&$sortChildren) { + if (!empty($node['children'])) { + usort($node['children'], function($a, $b) { + // Ethernet First + $aType = strtolower($a['type'] ?? ''); + $bType = strtolower($b['type'] ?? ''); + + // Check for 'repeater' in name to prioritize repeaters/access points in sorting if needed + // User said: "middle-mans" (repeaters) then devices. + // Let's prioritize Repeaters/LAN Bridges. + $aIsRepeater = stripos($a['name'] ?? '', 'repeater') !== false; + $bIsRepeater = stripos($b['name'] ?? '', 'repeater') !== false; + + if ($aIsRepeater && !$bIsRepeater) return -1; + if (!$aIsRepeater && $bIsRepeater) return 1; + + if ($aType === 'ethernet' && $bType !== 'ethernet') return -1; + if ($aType !== 'ethernet' && $bType === 'ethernet') return 1; + + // Then by Name + return strcasecmp($a['name'] ?? '', $b['name'] ?? ''); + }); + + foreach ($node['children'] as &$child) { + $sortChildren($child); + } + } + }; + + $sortChildren($fbox); + + self::returnJson(['root' => $fbox]); + } + } + + self::sendError("Failed to fetch network structure"); + } catch (Exception $e) { + $this->log->debug("Network Structure Error", ['error' => $e->getMessage()]); + self::sendError("Error: " . $e->getMessage()); + } + } + + + + private function ensureMacDb() { + + $path = TEMP_DIR . '/mac-vendors.csv'; + + if (!file_exists($path)) { + + $this->log->debug("Downloading MAC Vendor DB..."); + + $ctx = stream_context_create(['http'=> ['timeout' => 30]]); + + $data = @file_get_contents("https://maclookup.app/downloads/csv-database/get-db", false, $ctx); + + if ($data) file_put_contents($path, $data); + + } + + } + + + + private function getVendor($mac) { + + $mac = strtoupper(str_replace([':', '-', '.'], '', $mac)); + + if (strlen($mac) < 6) return null; + + + + $path = TEMP_DIR . '/mac-vendors.csv'; + + if (!file_exists($path)) return null; + + + + // Format as XX:XX:XX + + $prefix = substr($mac, 0, 2) . ':' . substr($mac, 2, 2) . ':' . substr($mac, 4, 2); + + + + // Use grep for speed if available, else fallback to basic search? + + // Assuming Linux env as per docker context. + + $cmd = "grep -m 1 \"^" . $prefix . "\" " . escapeshellarg($path); + + $output = shell_exec($cmd); + + + + if ($output) { + + $parts = str_getcsv($output); + + if (isset($parts[1])) return $parts[1]; + + } + + return null; + + } + + } + + \ No newline at end of file diff --git a/lib/GenieACS/GenieACS.php b/lib/GenieACS/GenieACS.php index 5688745e9..4f2b17ae8 100644 --- a/lib/GenieACS/GenieACS.php +++ b/lib/GenieACS/GenieACS.php @@ -13,476 +13,292 @@ class GenieACS { $this->baseurl = rtrim($baseurl, '/'); $this->username = $username; $this->password = $password; - - if (!$this->baseurl || !$this->username || !$this->password) { - throw new Exception("Invalid Arguments"); - } } - /** - * Authenticate and retrieve JWT token - * @return bool - * @throws Exception - */ private function _authenticate() { $session_key = "genieacs.{$this->baseurl}.jwt"; $session = new mfConfig($session_key); - // Check if we have a valid cached token (valid for 1 hour) if ($session->value() && (time() - $session->edit) < 3600) { $this->jwt_token = $session->value(); + $this->log->debug("GenieACS: Using cached JWT token."); return true; } - $url = $this->baseurl . '/login'; - - $ctx_options = [ + $this->log->debug("GenieACS: Authenticating to get new JWT token."); + $ctx = stream_context_create([ "http" => [ "ignore_errors" => true, "method" => "POST", - "header" => [ - "Accept: application/json, text/*", - "Content-Type: application/json; charset=UTF-8", - ], - "content" => json_encode([ - "username" => $this->username, - "password" => $this->password, - ]), + "header" => ["Content-Type: application/json"], + "content" => json_encode(["username" => $this->username, "password" => $this->password]), ] - ]; - - $ctx = stream_context_create($ctx_options); - $response = file_get_contents($url, false, $ctx); - - // Extract JWT from response headers + ]); + + $response = file_get_contents($this->baseurl . '/login', false, $ctx); + if (isset($http_response_header)) { foreach ($http_response_header as $header) { - if (stripos($header, 'set-cookie') !== false && stripos($header, 'genieacs-ui-jwt=') !== false) { - preg_match('/genieacs-ui-jwt=([^;]+)/', $header, $matches); - if (isset($matches[1])) { - $this->jwt_token = $matches[1]; - - // Cache the token - $session->value($this->jwt_token); - $session->save(); - - return true; - } + if (preg_match('/genieacs-ui-jwt=([^;]+)/', $header, $matches)) { + $this->jwt_token = $matches[1]; + $session->value($this->jwt_token); + $session->save(); + $this->log->debug("GenieACS: Successfully retrieved and cached new JWT token."); + return true; } } } - - throw new Exception("Authentication failed - could not retrieve JWT token"); - } - - /** - * Make a GET request to the API - * @param string $endpoint - * @return array|null - * @throws Exception - */ - private function _get($endpoint) { - if (!$this->jwt_token) { - $this->_authenticate(); - } - - $url = $this->baseurl . $endpoint; - - $ctx_options = [ - 'http' => [ - 'ignore_errors' => true, - 'method' => 'GET', - 'header' => [ - 'Cookie: genieacs-ui-jwt=' . $this->jwt_token, - 'Accept: application/json', - ], - ] - ]; - - $ctx = stream_context_create($ctx_options); - $response = file_get_contents($url, false, $ctx); - - // Check if we got a 401 and need to re-authenticate - if (isset($http_response_header)) { - foreach ($http_response_header as $header) { - if (stripos($header, 'HTTP/') === 0 && stripos($header, '401') !== false) { - // Token expired, re-authenticate and retry - $this->jwt_token = null; - $this->_authenticate(); - return $this->_get($endpoint); - } - } - } - - return json_decode($response, true); - } - - /** - * Make a POST request to the API - * @param string $endpoint - * @param array $data - * @return array|null - * @throws Exception - */ - private function _post($endpoint, $data) { - if (!$this->jwt_token) { - $this->_authenticate(); - } - - $url = $this->baseurl . $endpoint; - $jsonData = json_encode($data); - - $ctx_options = [ - 'http' => [ - 'ignore_errors' => true, - 'method' => 'POST', - 'header' => [ - 'Cookie: genieacs-ui-jwt=' . $this->jwt_token, - 'Accept: application/json', - 'Content-Type: application/json', - 'Content-Length: ' . strlen($jsonData) - ], - 'content' => $jsonData, - ] - ]; - - $ctx = stream_context_create($ctx_options); - $response = file_get_contents($url, false, $ctx); - - // Log for debugging - error_log("GenieACS POST to $url: " . $jsonData); - error_log("GenieACS response: " . ($response ?: 'empty')); - - // Check if we got a 401 and need to re-authenticate - if (isset($http_response_header)) { - foreach ($http_response_header as $header) { - error_log("GenieACS response header: " . $header); - if (stripos($header, 'HTTP/') === 0 && stripos($header, '401') !== false) { - // Token expired, re-authenticate and retry - $this->jwt_token = null; - $this->_authenticate(); - return $this->_post($endpoint, $data); - } - } - } - - // If response is empty or false, it might still be successful (204 No Content) - if ($response === false || $response === '') { - // Check if status code indicates success - if (isset($http_response_header)) { - foreach ($http_response_header as $header) { - if (stripos($header, 'HTTP/') === 0) { - if (stripos($header, '200') !== false || stripos($header, '202') !== false || stripos($header, '204') !== false) { - return ['success' => true]; - } - } - } - } - } - - return json_decode($response, true); - } - - /** - * Get all devices - * @return array|null - * @throws Exception - */ - public function getDevices() { - return $this->_get('/api/devices'); - } - - /** - * Get a specific device by ID - * @param string $deviceId Device ID (will be URL-encoded automatically) - * @return array|null - * @throws Exception - */ - public function getDevice($deviceId) { - return $this->_get('/api/devices/' . rawurlencode($deviceId)); - } - - /** - * Create a task for a device - * @param string $deviceId Device ID (will be URL-encoded automatically) - * @param array $tasks Array of tasks to execute - * @return array|null - * @throws Exception - * - * Example: - * $tasks = [ - * ["name" => "getParameterValues", "parameterNames" => ["InternetGatewayDevice.User.1.Username"]] - * ]; - */ - public function createTask($deviceId, $tasks) { - return $this->_post('/api/devices/' . rawurlencode($deviceId) . '/tasks', $tasks); - } - - /** - * Get parameter values from a device - * @param string $deviceId URL-encoded device ID - * @param array $parameterNames Array of parameter names to retrieve - * @return array|null - * @throws Exception - */ - public function getParameterValues($deviceId, $parameterNames) { - $tasks = [ - [ - "name" => "getParameterValues", - "parameterNames" => $parameterNames - ] - ]; - return $this->createTask($deviceId, $tasks); - } - - /** - * Set parameter values on a device - * @param string $deviceId URL-encoded device ID - * @param array $parameterValues Array of parameter name => value pairs - * @return array|null - * @throws Exception - */ - public function setParameterValues($deviceId, $parameterValues) { - // Convert associative array to GenieACS format: [["paramName", value, "type"], ...] - $formattedParams = []; - foreach ($parameterValues as $name => $value) { - // Determine XSD type based on value type - $xsdType = 'xsd:string'; // default - if (is_bool($value)) { - $xsdType = 'xsd:boolean'; - $value = $value ? true : false; // ensure proper boolean - } elseif (is_int($value)) { - $xsdType = 'xsd:int'; - } elseif (is_float($value)) { - $xsdType = 'xsd:double'; - } - - $formattedParams[] = [$name, $value, $xsdType]; - } - - $tasks = [ - [ - "name" => "setParameterValues", - "parameterValues" => $formattedParams - ] - ]; - return $this->createTask($deviceId, $tasks); - } - - /** - * Refresh device information - * @param string $deviceId URL-encoded device ID - * @return array|null - * @throws Exception - */ - public function refreshDevice($deviceId) { - $tasks = [ - [ - "name" => "refreshObject", - "objectName" => "" - ] - ]; - return $this->createTask($deviceId, $tasks); - } - - /** - * Reboot a device - * @param string $deviceId URL-encoded device ID - * @return array|null - * @throws Exception - */ - public function rebootDevice($deviceId) { - $tasks = [ - [ - "name" => "reboot" - ] - ]; - return $this->createTask($deviceId, $tasks); - } - - /** - * Factory reset a device - * @param string $deviceId URL-encoded device ID - * @return array|null - * @throws Exception - */ - public function factoryResetDevice($deviceId) { - $tasks = [ - [ - "name" => "factoryReset" - ] - ]; - return $this->createTask($deviceId, $tasks); - } - - /** - * Ping an IP address - * @param string $ip IP address to ping - * @return array|null - * @throws Exception - * - * Returns: { - * "packetsTransmitted": 3, - * "packetsReceived": 3, - * "packetLoss": 0, - * "min": 2.674, - * "avg": 3.054, - * "max": 3.34, - * "mdev": 0.28 - * } - */ - public function ping($ip) { - return $this->_get('/api/ping/' . $ip); - } - - /** - * Download a file from a device - * @param string $deviceId URL-encoded device ID - * @param string $fileType File type to download - * @param string $fileName Optional file name - * @return array|null - * @throws Exception - */ - public function downloadFile($deviceId, $fileType, $fileName = null) { - $task = [ - "name" => "download", - "fileType" => $fileType - ]; - - if ($fileName) { - $task["fileName"] = $fileName; - } - - return $this->createTask($deviceId, [$task]); - } - - /** - * Parse device data from API response to extract useful information - * @param array $deviceData Raw device data from API - * @return array Parsed device information - */ - public static function parseDeviceData($deviceData) { - $parsed = []; - - foreach ($deviceData as $key => $value) { - // Extract simple values - if (isset($value['value']) && is_array($value['value'])) { - $parsed[$key] = $value['value'][0]; - } - } - - return $parsed; - } - - /** - * Get device ID from device data - * @param array $deviceData Raw device data from API - * @return string|null Device ID - */ - public static function getDeviceId($deviceData) { - if (isset($deviceData['DeviceID.ID']['value'][0])) { - return $deviceData['DeviceID.ID']['value'][0]; - } - return null; - } - - /** - * Get MAC address from device data - * @param array $deviceData Raw device data from API - * @return string|null MAC address - */ - public static function getMacAddress($deviceData) { - // Try WAN connection MAC address first - if (isset($deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress']['value'][0])) { - return $deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress']['value'][0]; - } - return null; - } - - /** - * Get external IP address from device data - * @param array $deviceData Raw device data from API - * @return string|null External IP address - */ - public static function getExternalIP($deviceData) { - // Try to get from WAN IP Connection - if (isset($deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress']['value'][0])) { - return $deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress']['value'][0]; - } - - // Try alternative connection - if (isset($deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress']['value'][0])) { - return $deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress']['value'][0]; - } - - return null; - } - - /** - * Get management/local IP address from device data (private IP) - * @param array $deviceData Raw device data from API - * @return string|null Management IP address - */ - public static function getManagementIP($deviceData) { - // Check both WAN connections and return the one with a private IP - $ips = []; - - if (isset($deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress']['value'][0])) { - $ips[] = $deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress']['value'][0]; - } - - if (isset($deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress']['value'][0])) { - $ips[] = $deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress']['value'][0]; - } - - // Return the first private IP found (10.x.x.x, 172.16-31.x.x, 192.168.x.x) - foreach ($ips as $ip) { - if (self::isPrivateIP($ip)) { - return $ip; - } - } - - // If no private IP found, return first IP - return $ips[0] ?? null; - } - - /** - * Check if an IP address is in a private range - * @param string $ip IP address - * @return bool True if private IP - */ - private static function isPrivateIP($ip) { - $parts = explode('.', $ip); - if (count($parts) !== 4) return false; - - $first = (int)$parts[0]; - $second = (int)$parts[1]; - - // 10.0.0.0 - 10.255.255.255 - if ($first === 10) return true; - - // 172.16.0.0 - 172.31.255.255 - if ($first === 172 && $second >= 16 && $second <= 31) return true; - - // 192.168.0.0 - 192.168.255.255 - if ($first === 192 && $second === 168) return true; - + $this->log->debug("GenieACS: Failed to retrieve JWT token."); return false; } - /** - * Get device manufacturer, model, and version info - * @param array $deviceData Raw device data from API - * @return array Device info - */ + private function _request($method, $endpoint, $data = null) { + if (!$this->jwt_token && !$this->_authenticate()) { + throw new Exception("GenieACS Authentication failed."); + } + + $this->log->debug("GenieACS: Making API request", ['method' => $method, 'endpoint' => $endpoint]); + $opts = [ + 'http' => [ + 'ignore_errors' => true, + 'method' => $method, + 'header' => ['Cookie: genieacs-ui-jwt=' . $this->jwt_token, 'Content-Type: application/json'], + ] + ]; + if ($data) $opts['http']['content'] = json_encode($data); + + $ctx = stream_context_create($opts); + $response = @file_get_contents($this->baseurl . $endpoint, false, $ctx); + + // Re-auth on 401 + if (isset($http_response_header)) { + foreach ($http_response_header as $header) { + if (strpos($header, '401') !== false) { + $this->log->debug("GenieACS: 401 Unauthorized, re-authenticating."); + $this->jwt_token = null; + if ($this->_authenticate()) { + return $this->_request($method, $endpoint, $data); + } else { + throw new Exception("GenieACS Re-authentication failed."); + } + } + } + } + + if ($response === false || $response === '') { + // 200-204 check + if (isset($http_response_header)) { + foreach ($http_response_header as $header) { + if (strpos($header, 'HTTP/') === 0 && (strpos($header, '200') !== false || strpos($header, '202') !== false || strpos($header, '204') !== false)) { + return ['success' => true]; + } + } + } + } + + $decoded = json_decode($response, true); + // If request was GET /devices/ID, the response IS the device object. + // If request was GET /devices, it is an array of objects. + return $decoded; + } + + public function getDevices() { + return $this->_request('GET', '/api/devices'); + } + + public function getDevice($deviceId) { + return $this->_request('GET', '/api/devices/' . rawurlencode($deviceId)); + } + + public function rebootDevice($deviceId) { + return $this->_request('POST', '/api/devices/' . rawurlencode($deviceId) . '/tasks', [['name' => 'reboot']]); + } + + public function ping($ip) { + return $this->_request('GET', '/api/ping/' . $ip); + } + + public function getParameterValues($deviceId, $parameterNames) { + return $this->_request('POST', '/api/devices/' . rawurlencode($deviceId) . '/tasks', [[ + "name" => "getParameterValues", "parameterNames" => $parameterNames + ]]); + } + + public function setParameterValues($deviceId, $parameterValues) { + $formattedParams = []; + foreach ($parameterValues as $name => $value) { + $type = 'xsd:string'; + if (is_bool($value)) { $type = 'xsd:boolean'; $value = $value ? true : false; } + elseif (is_int($value)) $type = 'xsd:int'; + elseif (is_float($value)) $type = 'xsd:double'; + $formattedParams[] = [$name, $value, $type]; + } + return $this->_request('POST', '/api/devices/' . rawurlencode($deviceId) . '/tasks', [[ + "name" => "setParameterValues", "parameterValues" => $formattedParams + ]]); + } + + public function getSpeedtestResult($deviceId) { + $param = 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result'; + $this->getParameterValues($deviceId, [$param]); + usleep(500000); + $device = $this->getDevice($deviceId); + return self::getParam($device, $param); + } + + public function createRemoteUser($deviceId) { + $this->log->debug("GenieACS: createRemoteUser called", ['deviceId' => $deviceId]); + $cacheKey = "remote_user_" . $deviceId; + if ($cached = $this->getCache($cacheKey)) { + $this->log->debug("GenieACS: Using cached credentials"); + return $cached; + } + + $password = $this->generatePassword(12); + $timestamp = (string)time(); + + $userParamsToRefresh = [ + 'InternetGatewayDevice.User.1.Enable', + 'InternetGatewayDevice.User.1.Password', + 'InternetGatewayDevice.User.1.RemoteAccessCapable', + 'InternetGatewayDevice.User.1.Username' + ]; + $this->getParameterValues($deviceId, $userParamsToRefresh); + sleep(2); + + $this->setParameterValues($deviceId, [ + 'InternetGatewayDevice.User.1.Enable' => true, + 'InternetGatewayDevice.User.1.Password' => $password, + 'InternetGatewayDevice.User.1.RemoteAccessCapable' => true, + 'InternetGatewayDevice.User.1.Username' => $timestamp + ]); + + // Poll for Username + $username = null; + $maxAttempts = 15; + $paramName = 'InternetGatewayDevice.User.1.Username'; + + for ($i = 0; $i < $maxAttempts; $i++) { + sleep(1); + $this->getParameterValues($deviceId, [$paramName]); + usleep(500000); + + $device = $this->getDevice($deviceId); + + // Access property using flat dot-notation key + $val = self::getParam($device, $paramName); + $this->log->debug("GenieACS: Poll attempt " . ($i + 1) . " value: " . json_encode($val)); + + if ($val && strpos($val, 'TR069-') === 0) { + $username = $val; + break; + } + } + + if (!$username) { + $this->log->debug("GenieACS: Failed to retrieve TR069 username."); + return null; + } + + $ip = self::getExternalIP($this->getDevice($deviceId)); + if (!$ip) { + $this->log->debug("GenieACS: Could not get external IP."); + return null; + } + + $result = [ + 'username' => $username, + 'password' => $password, + 'ip' => $ip, + 'link' => "https://" . $ip . ":9090" + ]; + + $this->setCache($cacheKey, $result); + return $result; + } + + private function getCache($key) { + $file = TEMP_DIR . "/RadiusCache/" . md5($key) . ".json"; + if (file_exists($file)) { + if (filemtime($file) < (time() - 1800)) { + @unlink($file); + return null; + } + return json_decode(file_get_contents($file), true); + } + return null; + } + + private function setCache($key, $data) { + $dir = TEMP_DIR . "/RadiusCache/"; + if (!is_dir($dir)) @mkdir($dir, 0777, true); + file_put_contents($dir . md5($key) . ".json", json_encode($data)); + } + + private function generatePassword($length) { + $chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + return substr(str_shuffle(str_repeat($chars, ceil($length/strlen($chars)))), 1, $length); + } + + // Helpers to safely access device parameters from flat JSON structure + private static function getParam($deviceData, $key) { + if (!is_array($deviceData)) return null; + if (isset($deviceData[$key]['value'][0])) { + return $deviceData[$key]['value'][0]; + } + return null; + } + + public static function getExternalIP($deviceData) { + // Try typical WAN paths + $paths = [ + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress', + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress', + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ExternalIPAddress' + ]; + foreach ($paths as $path) { + $val = self::getParam($deviceData, $path); + if ($val) return $val; + } + return null; + } + + public static function getDeviceId($deviceData) { + return self::getParam($deviceData, 'DeviceID.ID'); + } + public static function getDeviceInfo($deviceData) { return [ - 'manufacturer' => $deviceData['DeviceID.Manufacturer']['value'][0] ?? null, - 'productClass' => $deviceData['DeviceID.ProductClass']['value'][0] ?? null, - 'oui' => $deviceData['DeviceID.OUI']['value'][0] ?? null, - 'serialNumber' => $deviceData['DeviceID.SerialNumber']['value'][0] ?? null, - 'hardwareVersion' => $deviceData['InternetGatewayDevice.DeviceInfo.HardwareVersion']['value'][0] ?? null, - 'softwareVersion' => $deviceData['InternetGatewayDevice.DeviceInfo.SoftwareVersion']['value'][0] ?? null, + 'serialNumber' => self::getParam($deviceData, 'DeviceID.SerialNumber'), + 'hardwareVersion' => self::getParam($deviceData, 'InternetGatewayDevice.DeviceInfo.HardwareVersion'), + 'softwareVersion' => self::getParam($deviceData, 'InternetGatewayDevice.DeviceInfo.SoftwareVersion'), ]; } -} + + public static function getManagementIP($deviceData) { + // Return any valid IP found, prioritizing private IPs if possible + $ip1 = self::getParam($deviceData, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress'); + $ip2 = self::getParam($deviceData, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress'); + + if ($ip1 && self::isPrivateIP($ip1)) return $ip1; + if ($ip2 && self::isPrivateIP($ip2)) return $ip2; + return $ip1 ?: $ip2; + } + + public static function getMacAddress($deviceData) { + $mac1 = self::getParam($deviceData, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress'); + if ($mac1) return $mac1; + return self::getParam($deviceData, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.MACAddress'); + } + + private static function isPrivateIP($ip) { + $parts = explode('.', $ip); + if (count($parts) !== 4) return false; + $first = (int)$parts[0]; + $second = (int)$parts[1]; + if ($first === 10) return true; + if ($first === 172 && $second >= 16 && $second <= 31) return true; + if ($first === 192 && $second === 168) return true; + return false; + } +} \ No newline at end of file diff --git a/lib/Helper/Helper.php b/lib/Helper/Helper.php index cf013a7ce..8126e8990 100644 --- a/lib/Helper/Helper.php +++ b/lib/Helper/Helper.php @@ -165,6 +165,33 @@ class Helper { $controller->layout()->setTemplate("VueViews/Vue"); } + /** + * Displays Vue 3 component with the given header title. + * Uses TT-Core component library instead of legacy Vue 2 components. + * + * @param mfBaseController $controller The controller instance to generate $JSGlobals for. + * @param string $pageName The name of the Vue component to render. + * @param string $headerTitle The title to display in the header. + * @param array $additionalGlobals Additional global variables to pass to the Vue component. + */ + public static function renderVue3(mfBaseController $controller, string $pageName, string $headerTitle, array $additionalGlobals = []) { + $JSGlobals = ["BASE_URL" => $controller::getUrl($pageName), + "MF_URL" => $controller::getUrl(""), + "DASHBOARD_URL" => $controller::getUrl("Dashboard"), + "MF_APP_NAME" => MFAPPNAME_SLUG, + "BASE_PATH" => $controller::getUrl(""), + "PAGE_TITLE" => $headerTitle, + "PATH" => [["text" => MFAPPNAME_SLUG, "href" => $controller::getUrl("Dashboard")], + ["text" => $headerTitle, "href" => $controller::getUrl($pageName)]],]; + + $JSGlobals = array_merge($JSGlobals, $additionalGlobals); + + $controller->layout()->set("vueViewName", $pageName); + $controller->layout()->set("JSGlobals", $JSGlobals); + $controller->layout()->set("useVue3", true); // Flag to indicate Vue 3 mode + $controller->layout()->setTemplate("VueViews/Vue3"); + } + /** * Converts an array of objects to a CSV file. * @param array $rows The array of objects to convert to CSV. diff --git a/public/js/pages/Radius/Radius.css b/public/js/pages/Radius/Radius.css index 5fc4970fa..d5f837d92 100644 --- a/public/js/pages/Radius/Radius.css +++ b/public/js/pages/Radius/Radius.css @@ -1,249 +1,246 @@ -/* ===== Radius.css ===== */ -:root{ --brand-blue: #005384; --bg: #ffffff; --card: #ffffff; --card-2: #f8fafc; --muted: #667085; --text: #0b1320; --accent: var(--brand-blue); --accent-2: #1e88c9; --ok: #0f9d58; --bad: #e03131; --ring: rgba(0,83,132,.20); --border: #e6e9ef; --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --radius: 10px; --radius-pill: 999px; --shadow: 0 8px 24px rgba(0, 83, 132, .08); } -.radius-scope a.link { color: var(--accent); text-decoration: none; font-weight: 500; transition: color .2s ease; } -.radius-scope a.link:hover { color: var(--accent-2); text-decoration: underline; } -.radius-scope .muted { color: var(--muted); } -.radius-scope .small { font-size: 12px; } -.radius-scope .mini { font-size: 11px; } -.radius-scope .mono { font-family: var(--mono); } -.radius-scope .center { text-align: center; } -.radius-scope .p-sm { padding: .5rem; } -.radius-scope .p-lg { padding: 1.25rem; } -.radius-scope .mt-2 { margin-top: .5rem; } -.radius-scope .mt-3 { margin-top: .75rem; } -.radius-scope .mt-between { margin-top: 12px; } -.radius-scope .nowrap { white-space: nowrap; } -.radius-scope .inline-copy { display:flex; align-items:center; gap:8px; justify-content: flex-end; } -.radius-scope .grid { display:grid; } -.radius-scope .g-2 { gap: 8px; } -.radius-scope .g-3 { gap: 12px; } -.radius-scope .g-4 { gap: 16px; } -.radius-scope .g-6 { gap: 24px; } -.radius-scope .cols-1 { grid-template-columns: 1fr; } -.radius-scope .cols-2 { grid-template-columns: repeat(2, minmax(0,1fr)); } -.radius-scope .cols-3 { grid-template-columns: repeat(3, minmax(0,1fr)); } -.radius-scope .cols-4 { grid-template-columns: repeat(4, minmax(0,1fr)); } -@media (max-width: 900px){ .radius-scope .cols-4 { grid-template-columns: repeat(2, minmax(0,1fr)); } } -@media (max-width: 600px){ .radius-scope .cols-4 { grid-template-columns: 1fr; } } -@media (min-width: 900px){ .radius-scope .cols-2@lg { grid-template-columns: repeat(2, minmax(0,1fr)); } .radius-scope .cols-4@lg { grid-template-columns: repeat(4, minmax(0,1fr)); } } -@media (min-width: 1200px){ .radius-scope .cols-2-xl { grid-template-columns: repeat(2, minmax(0,1fr)); } } -@media (max-width: 899.98px){ .radius-scope .cols-1@sm { grid-template-columns: 1fr; } } -.radius-scope .badge { display:inline-block; padding:2px 8px; border-radius:999px; background:#eef6fb; color:#0b3a57; font-size:12px; border:1px solid #d6e8f5; } -.radius-scope .h4 { font-size:18px; font-weight:800; letter-spacing:.2px; user-select: none; } -.radius-scope .h5 { font-size:16px; font-weight:800; letter-spacing:.2px; user-select: none; } -.radius-scope .cluster { display:flex; gap:10px; flex-wrap:wrap; align-items: center; } -.radius-scope.radius-container { background: transparent; color: var(--text); display: grid; gap: 16px; max-width: 90vw; margin: 0 auto; padding: 20px 0; } -.radius-scope .card, .radius-scope .subcard, .radius-scope .progress-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); } -.radius-scope .card { padding: 14px; } -.radius-scope .subcard { padding: 12px; } -.radius-scope .pane-header { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap: wrap;} -.radius-scope .pane-header .title { display:flex; align-items:center; gap:10px; font-weight:800; letter-spacing:.2px; font-size: 18px; user-select: none; } -.radius-scope .logo-dot { width:14px; height:14px; border-radius:50%; background: radial-gradient(circle at 30% 30%, #37d26b, #0f9d58 70%); box-shadow: 0 0 0 3px rgba(15,157,88,.15); display:inline-block; } -.radius-scope .view-tabs { display:flex; gap:8px; flex-wrap:wrap; } -.radius-scope .view-select-wrap { display: none; } -.radius-scope .content-divider { border: none; height: 1px; background-color: var(--border); margin: 16px 0; } -@media (max-width: 800px) { .radius-scope .view-tabs { display: none; } .radius-scope .view-select-wrap { display: block; } } -.radius-scope .tab-btn, .radius-scope .primary-btn, .radius-scope .ghost-btn, .radius-scope .icon-btn, .radius-scope .link-btn, .radius-scope .danger-btn { appearance:none; outline:none; border:none; cursor:pointer; font-weight:700; letter-spacing:.2px; transition: transform .12s ease, background .2s ease, border-color .2s ease, box-shadow .2s ease; user-select: none; } -.radius-scope .tab-btn { padding: 8px 12px; border-radius: var(--radius-pill); background: #f4f7fb; color: var(--text); border: 1px solid var(--border); } -.radius-scope .tab-btn.active, .radius-scope .tab-btn:hover { background: #eef6fb; border-color: #d6e8f5; box-shadow: 0 0 0 4px var(--ring); transform: scale(0.98); } -.radius-scope .tab-btn:disabled { opacity: .6; cursor: not-allowed; background: #f4f7fb; border-color: var(--border); box-shadow: none; transform: none; } -.radius-scope .primary-btn { padding: 8px 14px; border-radius: var(--radius); color:#fff; background: linear-gradient(135deg, var(--accent), var(--accent-2)); box-shadow: 0 6px 18px rgba(0,83,132,.25); height: 38px; display: inline-flex; align-items: center; justify-content: center; } -.radius-scope .primary-btn:disabled { opacity:.6; cursor:not-allowed; } -.radius-scope .ghost-btn { padding: 8px 12px; border-radius: var(--radius); color: var(--accent); background: #f8fbff; border:1px dashed #cfe4f3; display: inline-flex; align-items: center; justify-content: center; min-height: 38px; } -.radius-scope .danger-btn { padding: 8px 12px; border-radius: var(--radius); color: #c92a2a; background: #fff5f5; border: 1px dashed #ffc9c9; opacity: .9; transition: opacity .2s ease-in-out, transform .1s ease-in-out; } -.radius-scope .danger-btn:hover { opacity: 1; } -.radius-scope .danger-btn:active { transform: scale(0.97); } -.radius-scope .primary-btn:not(:disabled):hover, .radius-scope .ghost-btn:not(:disabled):hover, .radius-scope .danger-btn:not(:disabled):hover { transform: translateY(-2px); } -.radius-scope .primary-btn:not(:disabled):hover { box-shadow: 0 8px 22px rgba(0,83,132,.3); } -.radius-scope .icon-btn { background: transparent; color: var(--muted); padding: 6px 8px; border-radius:8px; } -.radius-scope .icon-btn.sm { padding: 4px 6px; } -.radius-scope .icon-btn:hover { color: var(--text); background:#f2f6fa; } -.radius-scope .link-btn { background: transparent; color: var(--accent); text-decoration: underline; } -@keyframes copy-feedback-pop { 0% { transform: scale(1); } 50% { transform: scale(1.1); } 100% { transform: scale(1); } } -.radius-scope [data-tooltip="Kopieren"], .radius-scope .ros-chip.is-clickable { user-select: none; -webkit-user-select: none; } -.radius-scope .icon-btn .check-icon { display: none; } -.radius-scope .icon-btn.is-copied, .radius-scope .icon-btn.is-copied:hover { background-color: #eaf7ef; color: var(--ok); animation: copy-feedback-pop 0.3s ease-in-out; } -.radius-scope .icon-btn.is-copied .copy-icon { display: none; } -.radius-scope .icon-btn.is-copied .check-icon { display: inline-block; } -.radius-scope .input-wrap { position: relative; } -.radius-scope .ri { box-sizing: border-box; width: 100%; padding: 8px 38px 8px 36px; border-radius: var(--radius); border: 1px solid var(--border); background: #fff; color: var(--text); transition: box-shadow .15s ease, border-color .15s ease, background .15s ease; } -.radius-scope .ac-root .ri { padding: 8px 38px 8px 75px; } -.radius-scope .ri:hover:not(:focus) { border-color: #c4d1de; } -.radius-scope .ri:focus { border-color: #bcd9ee; box-shadow: 0 0 0 5px var(--ring); outline: none; background: #fbfeff; } -.radius-scope .ri::placeholder{ color:#9aa6b2; } -.radius-scope .input-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color:#7997ad; font-size: 14px; pointer-events: none; } -.radius-scope .input-icon-logo { height: 20px; width: auto; opacity: 0.9; } -.radius-scope .btn-clear { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); width: 28px; height: 28px; border-radius: 8px; border: none; background: transparent; color:#5a7891; cursor: pointer; transition: all .2s ease; opacity: 1; } -.radius-scope .btn-clear:not(:disabled):hover { background:#e8f2f9; color:#2b5c7e; } -.radius-scope .btn-clear:disabled { background: transparent; color: #c1cbd5; cursor: not-allowed; transform: scale(0.9); opacity: 0.5; } -.radius-scope .btn-clear:disabled:hover { background: transparent; color: #c1cbd5; } -.radius-scope .logo-switcher { position: absolute; left: 1px; top: 1px; height: calc(100% - 2px); display: flex; align-items: center; gap: 8px; padding: 0 4px 0 8px; cursor: pointer; border-right: 1px solid var(--border); transition: background-color .2s ease; border-radius: 9px 0 0 9px; user-select: none; } -.radius-scope .logo-switcher:hover { background-color: #f8fafc; } -.radius-scope .switcher-caret { font-size: 11px; color: var(--muted); transition: transform .2s ease; } -.radius-scope .logo-switcher.is-open .switcher-caret { transform: rotate(180deg); } -.radius-scope .logo-dropdown { position: absolute; top: calc(100% + 6px); left: 0; background: #fff; border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); z-index: 25; padding: 6px; width: 180px; } -.radius-scope .logo-option { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; } -.radius-scope .logo-option:hover { background-color: #f3f8fc; } -.radius-scope .logo-option img { height: 18px; width: auto; } -.radius-scope .select select { width:100%; padding:10px 12px; border-radius: var(--radius); border:1px solid var(--border); background:#fff; -webkit-appearance: none; appearance: none; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); background-position: right .5rem center; background-repeat: no-repeat; background-size: 1.5em 1.5em; padding-right: 2.5rem; } -.radius-scope .switch-field { display:flex; flex-direction:column; gap:6px;align-items: center } -.radius-scope .switch { display:inline-flex; align-items:center; cursor:pointer; user-select:none; } -.radius-scope .switch input { display:none; } -.radius-scope .switch .switch-track { position: relative; width: 58px; height: 32px; border-radius: 999px; background: #e8edf3; border: 1px solid #d7e1ea; display:inline-flex; align-items:center; justify-content:space-between; padding: 0 8px; color:#7b8a98; transition: background .18s ease, border-color .18s ease, box-shadow .18s ease; } -.radius-scope .switch .on { opacity: 0; transition: opacity .18s ease; } -.radius-scope .switch .off { opacity: 1; transition: opacity .18s ease; } -.radius-scope .switch .switch-track::after { content: ""; position: absolute; top: 3px; left: 3px; width: 26px; height: 26px; background:#fff; border-radius: 50%; box-shadow: 0 2px 6px rgba(0,0,0,.08); transition: transform .18s ease, box-shadow .18s ease; } -.radius-scope .switch input:checked + .switch-track { background: linear-gradient(135deg, var(--accent), var(--accent-2)); border-color: #a9d0ea; color:#fff; } -.radius-scope .switch input:focus-visible + .switch-track { box-shadow: 0 0 0 5px var(--ring); } -.radius-scope .switch input:checked + .switch-track::after { transform: translateX(26px); } -.radius-scope .switch input:checked + .switch-track .on { opacity: 1; } -.radius-scope .switch input:checked + .switch-track .off { opacity: 0; } -.radius-scope .ac-root { position: relative; } -.radius-scope .ac-panel { position: absolute; left: 0; min-width: 100%; width: auto; margin-top: 6px; z-index: 20; background: #fff; border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow); padding: 8px; } -.radius-scope .ac-panel.wide, .radius-scope [data-wide="1"] .ac-panel { left: -6px; right: auto; } -.radius-scope .ac-skel .skeleton-line { height: 12px; margin: 8px 0; } -.radius-scope .ac-empty { padding: 10px; } -.radius-scope .ac-list { list-style: none; margin: 0; padding: 0; max-height: 260px; overflow: auto; } -.radius-scope .ac-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; cursor: pointer; transition: transform .1s ease, background-color .1s ease; white-space: nowrap; } -.radius-scope .ac-item:hover, .radius-scope .ac-item.is-active { background:#f3f8fc; transform: scale(0.99); } -.radius-scope .ac-more-info { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-top: 1px solid var(--border); background: var(--card-2); font-style: italic; cursor: default; } -.radius-scope .ac-more-info .txt { color: var(--muted); } -.radius-scope .ac-pop-enter-active, .radius-scope .ac-pop-leave-active { transition: opacity .12s ease, transform .12s ease; transform-origin: top center; } -.radius-scope .ac-pop-enter, .radius-scope .ac-pop-leave-to { opacity:0; transform: translateY(-4px) scale(.98); } -.radius-scope .filters-layout { display: grid; gap: 12px; align-items: flex-end; grid-template-columns: 1.5fr 210px 190px 1fr auto; } -@media (max-width: 1200px) { .radius-scope .filters-layout { grid-template-columns: 1fr 1fr; } .radius-scope .filters-layout .field:nth-child(1), .radius-scope .filters-layout .field:nth-child(4) { grid-column: 1 / -1; } } -@media (max-width: 768px) { .radius-scope .filters-layout { grid-template-columns: 1fr; } .radius-scope .filters-layout .field:nth-child(1), .radius-scope .filters-layout .field:nth-child(4) { grid-column: auto; } } -.radius-scope .field label { display:block; margin: 0 0 6px; color: var(--muted); font-size: 12px; } -.radius-scope .table-wrap { overflow:auto; border-radius: 12px; border:1px solid var(--border); background: var(--card-2); max-height: 65vh; } -.radius-scope .table-wrap::-webkit-scrollbar { width: 8px; height: 8px; } -.radius-scope .table-wrap::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 10px; } -.radius-scope .table-wrap::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; border: 2px solid #f1f5f9; } -.radius-scope .table-wrap::-webkit-scrollbar-thumb:hover { background: #94a3b8; } -.radius-scope .tt-table { width:100%; min-width: 1000px; border-collapse: collapse; background: #fff; table-layout: fixed; margin-bottom: unset !important; } -.radius-scope .tt-table.no-min-width { min-width: auto; } -.radius-scope .tt-table th, .radius-scope .tt-table td { padding: 10px 12px; border-bottom:1px solid #eef1f5; vertical-align: middle; } -.radius-scope .tt-table thead th { position:sticky; top:0; background:#f6f9fc; font-size:12px; color:#344054; text-transform:uppercase; letter-spacing:.04em; user-select: none; z-index: 10; } -.radius-scope .tt-table.compact th, .radius-scope .tt-table.compact td { padding:8px 10px; } -.radius-scope .tt-table.ultra-compact th, .radius-scope .tt-table.ultra-compact td { padding:6px 8px; font-size:12px; } -.radius-scope .rows-enter-active, .radius-scope .rows-leave-active { transition: opacity .12s ease, transform .12s ease; } -.radius-scope .rows-enter, .radius-scope .rows-leave-to { opacity:0; transform: translateY(2px); } -.radius-scope .results-container .table-wrap { border-radius: 12px 12px 0 0; border-bottom: none; } -.radius-scope .results-summary { padding: 8px 12px; border: 1px solid var(--border); border-top: none; background: #f6f9fc; font-size: 13px; color: var(--muted); border-radius: 0 0 12px 12px; min-height: 38px; display: flex; align-items: center; } -.radius-scope .table-placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 48px 24px; border: 1px solid var(--border); border-radius: 12px; background: var(--card-2); text-align: center; color: var(--muted); font-size: 16px; } -.radius-scope .table-placeholder i { font-size: 32px; color: var(--brand-blue); } -.radius-scope .row-fade-in { animation: rowIn .22s ease; } -@keyframes rowIn { from { opacity:0; transform: translateY(2px);} to {opacity:1; transform: none;} } -.radius-scope .skeleton-line { --h: 12px; height: var(--h); border-radius: 8px; background: linear-gradient(90deg, #eaeef3, #f3f6fa, #eaeef3); background-size: 300% 100%; animation: shimmer 1.1s infinite linear; } -@keyframes shimmer { 0%{background-position:0% 0} 100%{background-position:100% 0} } -.radius-scope .btn-loader { width: 18px; height: 18px; border: 2px solid #d5e7f4; border-top-color: var(--brand-blue); border-radius:50%; display:inline-block; animation: spin .9s linear infinite; } -@keyframes spin { to { transform: rotate(360deg);} } -.radius-scope.modal-overlay { position: fixed; inset:0; background: rgba(0,0,0,.25); display:flex; align-items:center; justify-content:center; padding: 20px; z-index: 9999; } -.radius-scope .modal-card { width:min(780px, 92vw); max-height: 88vh; overflow:auto; border-radius: 16px; border:1px solid var(--border); background: #fff; } -.radius-scope .modal-head { display:flex; align-items:center; justify-content:space-between; padding: 14px 16px; border-bottom:1px solid var(--border); position:sticky; top:0; background: #fff; z-index: 10; user-select: none; } -.radius-scope .modal-title { font-weight:800; } -.radius-scope .modal-body { padding: 14px 16px; } -.radius-scope .fade-enter-active, .radius-scope .fade-leave-active { transition: opacity .14s ease; } -.radius-scope .fade-enter, .radius-scope .fade-leave-to { opacity:0; } -.radius-scope .pop { animation: pop .16s ease; } -@keyframes pop { from { transform: scale(.98);} to { transform: none;} } -.radius-scope .kv { display:grid; grid-template-columns: 180px 1fr; gap: 10px 16px; } -.radius-scope .kv > div { display: contents; } -.radius-scope .kv > div > span { color: var(--muted); } -.radius-scope .kv-redesign { display: flex; flex-direction: column; } -.radius-scope .kv-redesign .kv-row { display: flex; align-items: flex-start; justify-content: space-between; padding: 12px 4px; border-bottom: 1px solid var(--border); gap: 16px; } -.radius-scope .kv-redesign .kv-row:last-child { border-bottom: none; } -.radius-scope .kv-redesign .kv-label { color: var(--muted); flex-shrink: 0; width: 140px; } -.radius-scope .kv-redesign .kv-value { flex-grow: 1; text-align: right; word-break: break-all; min-width: 0; } -.radius-scope .kv-redesign .chip { display:inline-flex; align-items:center; gap:6px; padding:3px 10px; border-radius:999px; font-size:12px; border:1px solid var(--border); } -.radius-scope .kv-redesign .chip.ok { background: #eaf7ef; color:#206a42; border-color: #c9e6d8; } -.radius-scope .kv-redesign .chip.bad { background: #fdecec; color:#8a1d1d; border-color: #f6d2d2; } -.radius-scope .ros-wrap { min-height: 28px; display:flex; align-items:center; justify-content:flex-start; width: 170px; } -.radius-scope .ros-chip { display:flex; align-items:center; gap:8px; padding:4px 8px; border-radius: var(--radius); font-size:12px; font-family: var(--mono); border:1px solid var(--border); background:#fff; width: 100%; height: 28px; box-sizing: border-box; } -.radius-scope .ros-chip.is-clickable { cursor: pointer; transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease; } -.radius-scope .ros-chip.is-clickable:hover { background-color: #f3f8fc; } -.radius-scope .ros-chip.on { box-shadow: 0 0 0 3px rgba(15,157,88,.08); } -.radius-scope .ros-chip.off { box-shadow: 0 0 0 3px rgba(224,49,49,.08); } -.radius-scope .ros-chip .dot { width:8px; height:8px; border-radius:50%; background: currentColor; color: inherit; flex-shrink: 0; } -.radius-scope .ros-chip.on .dot { background: var(--ok); } -.radius-scope .ros-chip.off .dot { background: var(--bad); } -.radius-scope .ros-chip .ip { flex-grow: 1; text-align: center; } -.radius-scope .ros-chip.skeleton { background: #f8fafc; color: #d1d9e4; align-items: center; } -.radius-scope .ros-chip.is-copied { background-color: #eaf7ef; border-color: #c9e6d8; box-shadow: 0 0 0 3px rgba(15, 157, 88, 0.25); animation: copy-feedback-pop 0.3s ease-in-out; } -.radius-scope .ont-card .block { background: #fff; border:1px solid var(--border); border-radius: var(--radius); padding: 14px; box-shadow: var(--shadow); } -.radius-scope .ont-card .block + .block { margin-top: 12px; } -.radius-scope .block-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom: 10px; flex-wrap: wrap; } -.radius-scope .file-drop { display: flex; align-items: center; justify-content: center; border: 2px dashed #cfe4f3; border-radius: var(--radius); padding: 20px; text-align: center; background: #f8fbff; cursor: pointer; transition: transform .2s ease, border-color .2s ease, box-shadow .2s ease, background-color .2s ease; min-height: 150px; } -.radius-scope .file-drop.is-dragover { transform: scale(1.02); border-color: var(--accent); background-color: #f0f8ff; box-shadow: 0 0 0 5px var(--ring); } -.radius-scope .file-cta { display:flex; flex-direction:column; gap:8px; align-items:center; justify-content:center; color:#365972; } -.radius-scope .overlay { position:fixed; inset:0; background:rgba(255,255,255,.8); backdrop-filter: blur(4px); display:flex; flex-direction:column; align-items:center; justify-content:center; z-index: 50; text-align: center; } -.radius-scope .ont-loading-card { background: transparent; border: none; box-shadow: none; width: min(520px, 90vw); padding: 16px; color: var(--text); } -.radius-scope .spinner-lg { width: 42px; height: 42px; border: 4px solid #dbe9f5; border-top-color: var(--brand-blue); border-radius: 50%; animation: spin .9s linear infinite; margin: 0 auto 10px; } -.radius-scope .animated-hourglass { animation: hourglass-turn 2s infinite linear; } -@keyframes hourglass-turn { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } -.radius-scope .progress-bar { height: 8px; background:#eef4f8; border-radius:999px; overflow:hidden; border:1px solid #e2ebf3; } -.radius-scope .progress-bar .bar { height:100%; width:0; background: linear-gradient(90deg, var(--accent), var(--accent-2)); transition: width .2s ease; } -.radius-scope .progress-bar.is-yellow .bar { background: linear-gradient(90deg, #f7b733, #fc4a1a); } -.radius-scope .alert.error { padding:10px 12px; border-radius:10px; border:1px solid #ffd6d6; background:#fff3f3; color:#8a1d1d; } -.radius-scope .card-in { animation: cardIn .18s ease; } -@keyframes cardIn { from{ opacity:0; transform: translateY(4px);} to { opacity:1; transform: none;} } -[data-tooltip] { position: relative; } -[data-tooltip]::before, [data-tooltip]::after { position: absolute; left: 50%; transform: translateX(-50%) translateY(0); opacity: 0; pointer-events: none; transition: all .18s ease-in-out; z-index: 10001; } -[data-tooltip]::before { content: ''; bottom: 100%; border: 5px solid transparent; border-top-color: #0b1320; } -[data-tooltip]::after { content: attr(data-tooltip); bottom: calc(100% + 5px); padding: 4px 8px; border-radius: 6px; background: #0b1320; color: #fff; font-size: 12px; font-weight: 500; white-space: nowrap; } -[data-tooltip]:hover::before, [data-tooltip]:hover::after { opacity: 1; transform: translateX(-50%) translateY(-4px); } -[data-tooltip-align="right"]::after { left: 0; transform: translateX(0); } -[data-tooltip-align="right"]::before { left: 1em; transform: translateX(-50%); } -[data-tooltip-align="right"]:hover::after, [data-tooltip-align="right"]:hover::before { transform: translateX(0) translateY(-4px); } -[data-tooltip-align="right"]:hover::before { transform: translateX(-50%) translateY(-4px); } -[data-tooltip-align="left"]::after { left: auto; right: 0; transform: translateX(0); } -[data-tooltip-align="left"]::before { left: auto; right: 1em; transform: translateX(-50%); } -[data-tooltip-align="left"]:hover::after, [data-tooltip-align="left"]:hover::before { transform: translateX(0) translateY(-4px); } -[data-tooltip-align="left"]:hover::before { transform: translateX(-50%) translateY(-4px); } -[data-tooltip-align="bottom"]::after { top: calc(100% + 5px); bottom: auto; } -[data-tooltip-align="bottom"]::before { top: 100%; bottom: auto; border-top-color: transparent; border-bottom-color: #0b1320; } -[data-tooltip-align="bottom"]:hover::before, [data-tooltip-align="bottom"]:hover::after { transform: translateX(-50%) translateY(4px); } -[data-tooltip-wrap="true"]::after { white-space: normal; max-width: 220px; text-align: center; } -/* NEW RULE FOR BOTTOM-LEFT ALIGNMENT */ -[data-tooltip-align="bottom-left"]::after { top: calc(100% + 5px); bottom: auto; left: auto; right: 0; transform: translateX(0); } -[data-tooltip-align="bottom-left"]::before { top: 100%; bottom: auto; border-top-color: transparent; border-bottom-color: #0b1320; left: auto; right: 1em; transform: translateX(50%); } -[data-tooltip-align="bottom-left"]:hover::after, [data-tooltip-align="bottom-left"]:hover::before { transform: translateY(4px); } -[data-tooltip-align="bottom-left"]:hover::before { transform: translateX(50%) translateY(4px); } -.radius-scope .ip-field-wrapper, .radius-scope .ac-root { position: relative; } -.radius-scope .ip-focus-tooltip, .radius-scope .ac-focus-tooltip { position: absolute; bottom: calc(100% + 4px); left: 0; background: #f8fbff; border: 1px solid #cfe4f3; padding: 4px 8px; border-radius: 6px; font-size: 11px; color: var(--accent); white-space: nowrap; opacity: 0; transform: translateY(4px); pointer-events: none; transition: all .18s ease-in-out; z-index: 10; } -.radius-scope .ip-field-wrapper:focus-within .ip-focus-tooltip, .radius-scope .ac-root:focus-within .ac-focus-tooltip { opacity: 1; transform: translateY(0); } -.radius-scope .modal-card-wide { width: min(1100px, 92vw); } -.radius-scope .modal-body-scrollable { max-height: calc(90vh - 120px); overflow-y: auto; padding-right: 8px; } -.radius-scope .unselectable { user-select: none; } -.radius-scope .custom-dropdown { position: relative; width: 120px; } -.radius-scope .dropdown-toggle { appearance: none; width: 100%; display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-radius: var(--radius); border: 1px solid var(--border); background: #fff; cursor: pointer; font-weight: 700; transition: all .2s ease; height: 38px; box-sizing: border-box; } -.radius-scope .dropdown-toggle:hover { border-color: #c4d1de; } -.radius-scope .dropdown-toggle:focus, .radius-scope .dropdown-toggle.is-open { border-color: #bcd9ee; box-shadow: 0 0 0 5px var(--ring); outline: none; } -.radius-scope .dropdown-toggle .fa-chevron-down { font-size: 12px; transition: transform .2s ease; } -.radius-scope .dropdown-toggle.is-open .fa-chevron-down { transform: rotate(180deg); } -.radius-scope .dropdown-panel { position: absolute; top: calc(100% + 6px); left: 0; width: 100%; background: #fff; border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); z-index: 25; padding: 6px; max-height: 200px; overflow-y: auto; } -.radius-scope .dropdown-item { padding: 8px 10px; border-radius: 6px; cursor: pointer; font-weight: 500; } -.radius-scope .dropdown-item:hover, .radius-scope .dropdown-item.is-active { background-color: #f3f8fc; } -.radius-scope .stat-card-v2 { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; display: flex; align-items: center; gap: 16px; background-color: var(--card-2); } -.radius-scope .stat-card-v2 .stat-icon { flex-shrink: 0; width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 20px; } -.radius-scope .stat-card-v2 .stat-label { font-size: 12px; color: var(--muted); margin-bottom: 2px; } -.radius-scope .stat-card-v2 .stat-value { font-size: 18px; font-weight: 700; line-height: 1.2; font-family: var(--mono); } -.radius-scope .stat-card-v2.stat-total .stat-icon { background-color: #eef2f6; color: #334155; } -.radius-scope .stat-card-v2.stat-total .stat-value { color: var(--text); } -.radius-scope .stat-card-v2.stat-download .stat-icon { background-color: #dcfce7; color: #16a34a; } -.radius-scope .stat-card-v2.stat-download .stat-value { color: #15803d; } -.radius-scope .stat-card-v2.stat-upload .stat-icon { background-color: #e0f2fe; color: #0284c7; } -.radius-scope .stat-card-v2.stat-upload .stat-value { color: var(--accent); } -.radius-scope .stat-card-v2.stat-duration .stat-icon { background-color: #eef2f6; color: #334155; } -.radius-scope .stat-card-v2.stat-duration .stat-value { color: var(--text); } -.radius-scope .stat-card-v2-skeleton { display: flex; align-items: center; gap: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); } -.radius-scope .stat-card-v2-skeleton .icon { width: 44px; height: 44px; border-radius: 50%; flex-shrink: 0; } -.radius-scope .stat-card-v2-skeleton .text { flex-grow: 1; } -.radius-scope .stat-card-v2-skeleton .label { height: 12px; width: 70%; margin-bottom: 6px; } -.radius-scope .stat-card-v2-skeleton .value { height: 18px; width: 90%; } -.radius-scope .chart-card { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; background: var(--card-2); position: relative; } -.radius-scope .chart-card canvas { max-height: calc(250px - 32px); } -.radius-scope .chart-placeholder { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: var(--muted); } -.radius-scope .chart-placeholder i { font-size: 32px; color: #bdc8d8; } -.radius-scope .modal-skeleton .skeleton-line { margin-bottom: 12px; } -.radius-scope .table-placeholder-fixed-height { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 12px; color: var(--muted); text-align: center; padding: 20px; background-color: var(--card-2); user-select: none; } -.radius-scope .table-placeholder-fixed-height i { font-size: 32px; color: #bdc8d8; } \ No newline at end of file +/* ===== Radius Module Styles ===== */ +/* General utilities moved to tt-core.css - this file contains ONLY Radius-specific styles */ + +/* CSS Variables for backwards compatibility */ +:root { + --brand-blue: #005384; + --bg: #ffffff; + --card: #ffffff; + --card-2: #f8fafc; + --muted: #667085; + --text: #0b1320; + --accent: var(--brand-blue); + --accent-2: #1e88c9; + --ok: #0f9d58; + --bad: #e03131; + --ring: rgba(0,83,132,.20); + --border: #e6e9ef; + --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --radius: 10px; + --radius-pill: 999px; + --shadow: 0 8px 24px rgba(0, 83, 132, .08); + --line-offset: 32px; +} + +/* Radius-specific layouts */ +.tt-scope .free-users-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +@media (max-width: 1100px) { .tt-scope .free-users-grid { grid-template-columns: 1fr; } } +.tt-scope .free-users-column { display: flex; flex-direction: column; gap: 12px; } +.tt-scope .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #eef6fb; color: #0b3a57; font-size: 12px; border: 1px solid #d6e8f5; } +.tt-scope.radius-container { background: transparent; color: var(--text); display: grid; gap: 16px; max-width: 90vw; margin: 24px auto 0; padding: 20px 0; } +.tt-scope .subcard { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 12px; } +.tt-scope .pane-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; background: linear-gradient(135deg, #f8fbff 0%, #f0f7fd 100%); padding: 16px 20px; margin: -14px -14px 14px -14px; border-radius: var(--radius) var(--radius) 0 0; border-bottom: 2px solid #e3f0f8; } +.tt-scope .pane-header .title { display: flex; align-items: center; gap: 12px; font-weight: 800; letter-spacing: .4px; font-size: 22px; user-select: none; color: var(--accent); text-shadow: 0 1px 2px rgba(0,83,132,.1); } +.tt-scope .logo-dot { width: 14px; height: 14px; border-radius: 50%; background: radial-gradient(circle at 30% 30%, #37d26b, #0f9d58 70%); box-shadow: 0 0 0 3px rgba(15,157,88,.15); display: inline-block; } +.tt-scope .content-divider { border: none; height: 1px; background-color: var(--border); margin: 16px 0; } + +/* Switch Field */ +.tt-scope .switch-field { display: flex; flex-direction: column; gap: 6px; align-items: center; } +.tt-scope .switch { display: inline-flex; align-items: center; cursor: pointer; user-select: none; } +.tt-scope .switch input { display: none; } +.tt-scope .switch .switch-track { position: relative; width: 58px; height: 32px; border-radius: 999px; background: #e8edf3; border: 1px solid #d7e1ea; display: inline-flex; align-items: center; justify-content: space-between; padding: 0 8px; color: #7b8a98; transition: background .18s ease, border-color .18s ease, box-shadow .18s ease; } +.tt-scope .switch .on { opacity: 0; transition: opacity .18s ease; } +.tt-scope .switch .off { opacity: 1; transition: opacity .18s ease; } +.tt-scope .switch .switch-track::after { content: ""; position: absolute; top: 3px; left: 3px; width: 26px; height: 26px; background: #fff; border-radius: 50%; box-shadow: 0 2px 6px rgba(0,0,0,.08); transition: transform .18s ease, box-shadow .18s ease; } +.tt-scope .switch input:checked + .switch-track { background: linear-gradient(135deg, var(--accent), var(--accent-2)); border-color: #a9d0ea; color: #fff; } +.tt-scope .switch input:focus-visible + .switch-track { box-shadow: 0 0 0 5px var(--ring); } +.tt-scope .switch input:checked + .switch-track::after { transform: translateX(26px); } +.tt-scope .switch input:checked + .switch-track .on { opacity: 1; } +.tt-scope .switch input:checked + .switch-track .off { opacity: 0; } + +/* Filters Layout */ +.tt-scope .filters-layout { display: grid; gap: 12px; align-items: flex-end; grid-template-columns: 1.5fr 210px 190px 1fr auto; margin-bottom: 16px; } +@media (max-width: 1400px) { .tt-scope .filters-layout { grid-template-columns: 1fr 1fr 1fr; } .tt-scope .filters-layout .field:nth-child(1) { grid-column: 1 / -1; } } +@media (max-width: 900px) { .tt-scope .filters-layout { grid-template-columns: 1fr 1fr; } .tt-scope .filters-layout .field:nth-child(1), .tt-scope .filters-layout .field:nth-child(4) { grid-column: 1 / -1; } } +@media (max-width: 600px) { .tt-scope .filters-layout { grid-template-columns: 1fr; } .tt-scope .filters-layout .field { grid-column: auto !important; } } +.tt-scope .field label { display: block; margin: 0 0 6px; color: var(--muted); font-size: 12px; } +.tt-scope .results-container .table-wrap { border-radius: 12px 12px 0 0; border-bottom: none; } +.tt-scope .inline-copy { display: flex; align-items: center; gap: 8px; justify-content: flex-end; } +.tt-scope .clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.tt-scope .clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } + +/* KV Layouts */ +.tt-scope .kv { display: grid; grid-template-columns: 180px 1fr; gap: 10px 16px; } +.tt-scope .kv > div { display: contents; } +.tt-scope .kv > div > span { color: var(--muted); } + +/* Key-Value Redesign Layout - moved to tt-core.css */ + +/* Radius Online Status Chip */ +.tt-scope .ros-wrap { min-height: 28px; display: flex; align-items: center; justify-content: flex-start; width: 170px; } +.tt-scope .ros-chip { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-radius: var(--radius); font-size: 12px; font-family: var(--mono); border: 1px solid var(--border); background: #fff; width: 100%; height: 28px; box-sizing: border-box; } +.tt-scope .ros-chip.is-clickable { cursor: pointer; transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease; } +.tt-scope .ros-chip.is-clickable:hover { background-color: #f3f8fc; } +.tt-scope [data-tooltip="Kopieren"], .tt-scope .ros-chip.is-clickable { user-select: none; -webkit-user-select: none; } +.tt-scope .ros-chip.on { box-shadow: 0 0 0 3px rgba(15,157,88,.08); } +.tt-scope .ros-chip.off { box-shadow: 0 0 0 3px rgba(224,49,49,.08); } +.tt-scope .ros-chip .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; color: inherit; flex-shrink: 0; } +.tt-scope .ros-chip.on .dot { background: var(--ok); } +.tt-scope .ros-chip.off .dot { background: var(--bad); } +.tt-scope .ros-chip .ip { flex-grow: 1; text-align: center; } +.tt-scope .ros-chip.skeleton { background: #f8fafc; color: #d1d9e4; align-items: center; } +.tt-scope .ros-chip.is-copied { background-color: #eaf7ef; border-color: #c9e6d8; box-shadow: 0 0 0 3px rgba(15, 157, 88, 0.25); animation: copy-feedback-pop 0.3s ease-in-out; } + +/* ONT Card Styles */ +.tt-scope .ont-card .block { background: #fff; border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; box-shadow: var(--shadow); } +.tt-scope .ont-card .block + .block { margin-top: 12px; } +.tt-scope .block-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; } + +/* Radius-Specific Tooltips */ +.tt-scope .ip-field-wrapper, .tt-scope .ac-root { position: relative; } +.tt-scope .ip-focus-tooltip, .tt-scope .ac-focus-tooltip { position: absolute; bottom: calc(100% + 8px); left: 0; background: linear-gradient(135deg, #e3f0f8 0%, #d6e8f5 100%); border: 1px solid #b8d9f0; padding: 6px 12px; border-radius: 8px; font-size: 11px; font-weight: 600; color: #0b3a57; white-space: nowrap; opacity: 0; transform: translateY(6px); pointer-events: none; transition: all .22s cubic-bezier(0.34, 1.56, 0.64, 1); z-index: 50; box-shadow: 0 4px 12px rgba(0, 83, 132, .15), 0 0 0 1px rgba(255, 255, 255, .8) inset; } +.tt-scope .ip-focus-tooltip::before, .tt-scope .ac-focus-tooltip::before { content: ''; position: absolute; top: 100%; left: 16px; border: 6px solid transparent; border-top-color: #d6e8f5; transform: translateY(-1px); } +.tt-scope .ip-focus-tooltip::after, .tt-scope .ac-focus-tooltip::after { content: ''; position: absolute; top: 100%; left: 17px; border: 5px solid transparent; border-top-color: #e3f0f8; } +.tt-scope .ip-field-wrapper:focus-within .ip-focus-tooltip, .tt-scope .ac-root:focus-within .ac-focus-tooltip { opacity: 1; transform: translateY(0); } + +/* Modal & Misc */ +.tt-scope .modal-body-scrollable { max-height: calc(90vh - 120px); overflow-y: auto; padding-right: 8px; overflow-x: hidden; } +.tt-scope .unselectable { user-select: none; } + +/* Custom Dropdown */ +.tt-scope .custom-dropdown { position: relative; width: 120px; } +.tt-scope .dropdown-toggle { appearance: none; width: 100%; display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-radius: var(--radius); border: 1px solid var(--border); background: #fff; cursor: pointer; font-weight: 700; transition: all .2s ease; height: 38px; box-sizing: border-box; } +.tt-scope .dropdown-toggle:hover { border-color: #c4d1de; } +.tt-scope .dropdown-toggle:focus, .tt-scope .dropdown-toggle.is-open { border-color: #bcd9ee; box-shadow: 0 0 0 5px var(--ring); outline: none; } +.tt-scope .dropdown-toggle .fa-chevron-down { font-size: 12px; transition: transform .2s ease; } +.tt-scope .dropdown-toggle.is-open .fa-chevron-down { transform: rotate(180deg); } +.tt-scope .dropdown-panel { position: absolute; top: calc(100% + 6px); left: 0; width: 100%; background: #fff; border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); z-index: 25; padding: 6px; max-height: 200px; overflow-y: auto; } +.tt-scope .dropdown-item { padding: 8px 10px; border-radius: 6px; cursor: pointer; font-weight: 500; } +.tt-scope .dropdown-item:hover, .tt-scope .dropdown-item.is-active { background-color: #f3f8fc; } + +/* Stat Cards V2 */ +.tt-scope .stat-card-v2 { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; display: flex; align-items: center; gap: 16px; background-color: var(--card-2); } +.tt-scope .stat-card-v2 .stat-icon { flex-shrink: 0; width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 20px; } +.tt-scope .stat-card-v2 .stat-label { font-size: 12px; color: var(--muted); margin-bottom: 2px; } +.tt-scope .stat-card-v2 .stat-value { font-size: 18px; font-weight: 700; line-height: 1.2; font-family: var(--mono); } +.tt-scope .stat-card-v2.stat-total .stat-icon { background-color: #eef2f6; color: #334155; } +.tt-scope .stat-card-v2.stat-total .stat-value { color: var(--text); } +.tt-scope .stat-card-v2.stat-download .stat-icon { background-color: #dcfce7; color: #16a34a; } +.tt-scope .stat-card-v2.stat-download .stat-value { color: #15803d; } +.tt-scope .stat-card-v2.stat-upload .stat-icon { background-color: #e0f2fe; color: #0284c7; } +.tt-scope .stat-card-v2.stat-upload .stat-value { color: var(--accent); } +.tt-scope .stat-card-v2.stat-duration .stat-icon { background-color: #eef2f6; color: #334155; } +.tt-scope .stat-card-v2.stat-duration .stat-value { color: var(--text); } + +/* Chart Card */ +.tt-scope .chart-card { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; background: var(--card-2); position: relative; } +.tt-scope .chart-card canvas { max-height: calc(250px - 32px); } +.tt-scope .chart-placeholder { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: var(--muted); } +.tt-scope .chart-placeholder i { font-size: 32px; color: #bdc8d8; } +.tt-scope .table-placeholder-fixed-height { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 12px; color: var(--muted); text-align: center; padding: 20px; background-color: var(--card-2); user-select: none; } +.tt-scope .table-placeholder-fixed-height i { font-size: 32px; color: #bdc8d8; } +.tt-scope .overlay { position: fixed; inset: 0; background: rgba(255,255,255,.8); backdrop-filter: blur(4px); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 50; text-align: center; } +.tt-scope .ont-loading-card { background: transparent; border: none; box-shadow: none; width: min(520px, 90vw); padding: 16px; color: var(--text); } +.tt-scope .spinner-lg { width: 42px; height: 42px; border: 4px solid #dbe9f5; border-top-color: var(--brand-blue); border-radius: 50%; animation: spin .9s linear infinite; margin: 0 auto 10px; } +.tt-scope .alert.error { padding: 10px 12px; border-radius: 10px; border: 1px solid #ffd6d6; background: #fff3f3; color: #8a1d1d; } +.tt-scope .card-in { animation: cardIn .18s ease; } +@keyframes cardIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } + +/* Network Mesh Visualization */ +.tt-scope .network-tree-container { overflow: auto; padding: 40px; display: inline-flex; justify-content: flex-start; align-items: flex-start; } +.tt-scope .mesh-node { display: flex; flex-direction: row; align-items: flex-start; position: relative; } +.tt-scope .mesh-content { border: 1px solid #e0e0e0; border-radius: 8px; padding: 10px; width: 240px; background: white; box-shadow: 0 2px 4px rgba(0,0,0,0.05); display: flex; align-items: center; gap: 10px; z-index: 2; position: relative; transition: all 0.2s ease; margin: 5px 0; } +.tt-scope .mesh-content:hover { box-shadow: 0 4px 8px rgba(0,0,0,0.1); border-color: #ccc; } +.tt-scope .mesh-content.is-router { border-color: #005384; background-color: #f0f7fa; } +.tt-scope .mesh-content.is-repeater { border-color: #0f9d58; background-color: #f0fbf5; } +.tt-scope .mesh-content.is-offline { opacity: 0.7; filter: grayscale(100%); } +.tt-scope .mesh-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-size: 20px; color: #555; position: relative; flex-shrink: 0; } +.tt-scope .conn-badge { position: absolute; bottom: -2px; right: -2px; width: 16px; height: 16px; background: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; box-shadow: 0 1px 2px rgba(0,0,0,0.2); } +.tt-scope .conn-badge.wlan { color: #005384; } +.tt-scope .conn-badge.eth { color: #0f9d58; } +.tt-scope .mesh-info { flex-grow: 1; min-width: 0; } +.tt-scope .mesh-name { font-weight: 600; font-size: 13px; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tt-scope .mesh-meta { display: flex; gap: 6px; align-items: center; margin-top: 2px; } +.tt-scope .mesh-ip, .tt-scope .mesh-mac { font-size: 10px; color: #777; font-family: var(--mono); } +.tt-scope .mesh-vendor { font-size: 10px; color: var(--accent); font-weight: 500; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tt-scope .mesh-details { font-size: 10px; color: #555; margin-top: 4px; background: #eee; padding: 1px 4px; border-radius: 4px; display: inline-block; } +.tt-scope .mesh-children { display: flex; flex-direction: column; margin-left: 80px; position: relative; justify-content: center; } +.tt-scope .mesh-branch { display: flex; align-items: center; position: relative; padding-left: 40px; } +.tt-scope .mesh-branch::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; border-left: 2px solid #ccc; display: block; } +.tt-scope .mesh-branch::after { content: ''; position: absolute; left: 0; top: var(--line-offset); width: 40px; border-top: 2px solid #ccc; } +.tt-scope .mesh-branch:first-child::before { top: var(--line-offset); } +.tt-scope .mesh-branch:last-child::before { bottom: auto; height: var(--line-offset); } +.tt-scope .mesh-branch:only-child::before { display: none; } +.tt-scope .mesh-content::before { content: ''; position: absolute; left: -40px; top: var(--line-offset); width: 40px; border-top: 2px solid #ccc; } +.tt-scope .network-tree-container > .mesh-node > .mesh-content::before { display: none; } +.tt-scope .mesh-node > .mesh-content:not(:last-child)::after { content: ''; position: absolute; right: -80px; top: var(--line-offset); width: 80px; border-top: 2px solid #ccc; } + +/* Tooltip Fixes for Table Actions */ +.tt-scope .table-wrap [data-tooltip]::before, +.tt-scope .table-wrap [data-tooltip]::after { + position: fixed; + z-index: 10002; +} + +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="left"]::before, +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="left"]::after { + left: auto; + right: 100%; + transform: translateX(0) translateY(-50%); +} + +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="left"]::before { + top: 50%; + bottom: auto; + border: 5px solid transparent; + border-left-color: #0b1320; + border-top-color: transparent; + margin-right: -10px; +} + +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="left"]::after { + top: 50%; + bottom: auto; + margin-right: -5px; +} + +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="left"]:hover::before, +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="left"]:hover::after { + transform: translateX(-4px) translateY(-50%); +} + +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="right"]::before, +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="right"]::after { + left: 100%; + right: auto; + transform: translateX(0) translateY(-50%); +} + +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="right"]::before { + top: 50%; + bottom: auto; + border: 5px solid transparent; + border-right-color: #0b1320; + border-top-color: transparent; + margin-left: -10px; +} + +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="right"]::after { + top: 50%; + bottom: auto; + margin-left: -5px; +} + +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="right"]:hover::before, +.tt-scope .table-wrap [data-tooltip][data-tooltip-align="right"]:hover::after { + transform: translateX(4px) translateY(-50%); +} + +/* Router Management Modal */ +.tt-scope .router-info-header { display: flex; align-items: center; gap: 12px; padding: 20px 24px; margin: -14px -24px 12px -16px; background: linear-gradient(135deg, #e3f0f8 0%, #cce4f5 100%); border-bottom: 2px solid #b8d9f0; } +.tt-scope .router-info-header i { font-size: 28px; color: var(--accent); flex-shrink: 0; } +.tt-scope .router-header-text { flex-grow: 1; min-height: 39px; } +.tt-scope .router-title { font-size: 16px; font-weight: 800; color: var(--text); letter-spacing: 0.3px; line-height: 1.375; } +.tt-scope .router-subtitle { font-size: 12px; color: var(--muted); font-family: var(--mono); margin-top: 2px; line-height: 1.25; } +.tt-scope .router-info-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 16px; margin-right: -8px; } +@media (max-width: 700px) { .tt-scope .router-info-grid { grid-template-columns: 1fr; } } + +/* Info Card Styles - moved to tt-core.css (TtInfoCard component) */ + +.tt-scope .router-actions-section { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); margin-right: -8px; } +.tt-scope .router-actions-header { display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 13px; font-weight: 800; color: var(--text); margin-bottom: 12px; letter-spacing: 0.3px; text-transform: uppercase; user-select: none; } +.tt-scope .router-actions-header i { font-size: 14px; color: var(--accent); } +.tt-scope .router-actions-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; } +@media (max-width: 700px) { .tt-scope .router-actions-grid { grid-template-columns: 1fr; } } +.tt-scope .action-btn { display: flex; align-items: center; padding: 8px 16px; } +.tt-scope .action-btn i { width: 20px; flex-shrink: 0; text-align: center; margin-right: 10px; } diff --git a/public/js/pages/Radius/Radius.js b/public/js/pages/Radius/Radius.js index 13891b3be..f7f97cd2a 100644 --- a/public/js/pages/Radius/Radius.js +++ b/public/js/pages/Radius/Radius.js @@ -1,360 +1,104 @@ -/* ===== Radius.js ===== */ - -/* ---------- Shared Utilities (global) ---------- */ -function loadScript(src) { - return new Promise((resolve, reject) => { - if (document.querySelector(`script[src="${src}"]`)) { - return resolve(); - } - const script = document.createElement('script'); - script.src = src; - script.onload = resolve; - script.onerror = () => reject(new Error(`Script load error for ${src}`)); - document.head.appendChild(script); - }); -} -async function copyToClipboard(text) { - try { - await navigator.clipboard.writeText(text || ''); - return true; - } catch { - const ta = document.createElement('textarea'); - ta.value = text || ''; - ta.style.position = 'fixed'; ta.style.opacity = '0'; - document.body.appendChild(ta); ta.select(); - try { document.execCommand('copy'); } catch {} - document.body.removeChild(ta); - return false; - } -} -function formatBytes(bytes, decimals = 2) { - bytes = parseInt(bytes, 10); - if (!bytes || bytes === 0) return '0 Bytes'; - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; -} -function formatDuration(seconds) { - if (!seconds || seconds < 0) return '0s'; - seconds = parseInt(seconds, 10); - const d = Math.floor(seconds / (3600*24)); - const h = Math.floor(seconds % (3600*24) / 3600); - const m = Math.floor(seconds % 3600 / 60); - if (d > 0) return `${d}t ${h}h`; - if (h > 0) return `${h}h ${m}m`; - if (m > 0) return `${m}m`; - return `< 1m`; -} -function calculateSimilarity(str1, str2) { - if (!str1 || !str2) return 0; - str1 = ('' + str1).toLowerCase(); - str2 = ('' + str2).toLowerCase(); - let match = 0; - for (let c of str1) if (str2.includes(c)) match++; - return (match / str1.length) * 100; -} -function validateData(strasse, plz, stadt, info) { - const thresholds = 90; - return !( - calculateSimilarity(strasse, info) < thresholds || - calculateSimilarity(plz, info) < thresholds || - calculateSimilarity(stadt, info) < thresholds - ); -} - -window.RadiusUtils = { calculateSimilarity, validateData, copyToClipboard, formatBytes, formatDuration, loadScript }; - -/* ---------- Reusable Component: radius-table-view ---------- */ -Vue.component('radius-table-view', { - props: { - items: Array, - isLoading: Boolean, - hasSearched: Boolean, - density: { type: String, default: 'compact' }, - tableClass: { type: String, default: '' }, - tableStyle: Object, - tableMinHeight: { type: String, default: 'auto' }, - initialPlaceholderIcon: { type: String, default: 'fa-duotone fa-keyboard' }, - initialPlaceholderText: { type: String, default: 'Beginnen Sie Ihre Suche.' }, - noResultsPlaceholderIcon: { type: String, default: 'fa-duotone fa-database' }, - noResultsPlaceholderText: { type: String, default: 'Keine Ergebnisse gefunden.' }, - skeletonRowCount: { type: Number, default: 6 } - }, - template: ` -
-
- -
{{ initialPlaceholderText }}
-
-
- -
- - - -
-
-
-
-
- -
{{ noResultsPlaceholderText }}
-
- -
- ` -}); - -/* ---------- Reusable Component: radius-file-drop ---------- */ -Vue.component('radius-file-drop', { - data: () => ({ dragCounter: 0 }), - computed: { isDragging() { return this.dragCounter > 0; } }, - template: ` - - `, - methods: { onDrop(e) { this.dragCounter = 0; const file = e.dataTransfer.files?.[0]; if (file) this.$emit('file-selected', file); } } -}); - -/* ---------- Reusable Component: radius-processing-indicator ---------- */ -Vue.component('radius-processing-indicator', { - props: ['progress', 'currentRow', 'totalRows', 'currentSerial'], - template: ` -
- -
Verarbeitung läuft...
-

Aktuell: {{ currentSerial || '—' }}

-
-
Verarbeite Zeile {{ currentRow + 1 }} von {{ totalRows }}
-
- ` -}); - -/* ---------- Online state chip (fetches radacct when visible) ---------- */ -Vue.component('radius-online-state', { - props: { username: String }, - data: () => ({ - data: null, - observed: false, - ob: null, - isHovering: false, - ctrlPressed: false, - tooltipText: 'IP-Adresse kopieren' - }), - template: ` -
- - -
- `, - watch: { - data(newData) { - // Update tooltip text when data is loaded - if (newData && newData.ip) { - this.tooltipText = 'IP-Adresse kopieren'; - } else { - this.tooltipText = null; - } - } - }, - mounted() { - this.ob = new IntersectionObserver((en) => { if (en[0].isIntersecting && !this.observed) { this.observed = true; this.fetchState(); } }, { threshold: 0.1 }); - if (this.$refs.root) this.ob.observe(this.$refs.root); - // Listen for Ctrl/Meta key presses globally - document.addEventListener('keydown', this.handleKey); - document.addEventListener('keyup', this.handleKey); - }, - beforeDestroy() { - this.ob?.disconnect(); - // Clean up global listeners - document.removeEventListener('keydown', this.handleKey); - document.removeEventListener('keyup', this.handleKey); - }, - methods: { - async fetchState() { - try { - const r = await fetch(`${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(this.username)}`); - this.data = r.ok ? await r.json() : { online: false, ip: null }; - } catch { - this.data = { online: false, ip: null }; - } - }, - async copyIp(event) { - if (!this.data?.ip) return; - const c = event.currentTarget; - if (!c || c.classList.contains('is-copied')) return; - await window.RadiusUtils.copyToClipboard(this.data.ip); - c.classList.add('is-copied'); - - // Temporarily change tooltip to "Kopiert!" - const originalTooltip = this.tooltipText; - this.tooltipText = 'Kopiert!'; - - setTimeout(() => { - c.classList.remove('is-copied'); - // Restore original tooltip - this.tooltipText = originalTooltip; - // Re-run updateTooltip in case Ctrl is still pressed - this.updateTooltip(); - }, 1500); - }, - // --- New methods for Ctrl+Click --- - handleKey(event) { - const newCtrlPressed = event.ctrlKey || event.metaKey; - if (newCtrlPressed !== this.ctrlPressed) { - this.ctrlPressed = newCtrlPressed; - // If hovering, update tooltip live - if (this.isHovering) { - this.updateTooltip(); - } - } - }, - onIpMouseOver(event) { - this.isHovering = true; - this.ctrlPressed = event.ctrlKey || event.metaKey; - this.updateTooltip(); - }, - onIpMouseOut() { - this.isHovering = false; - this.ctrlPressed = false; // Reset on mouse out - this.updateTooltip(); - }, - updateTooltip() { - if (!this.data?.ip) { - this.tooltipText = null; - } else if (this.isHovering && this.ctrlPressed) { - this.tooltipText = 'Scan starten & verbinden'; - } else { - this.tooltipText = 'IP-Adresse kopieren'; - } - }, - onClickIp(event) { - if (!this.data?.ip) return; - - if (event.ctrlKey || event.metaKey) { - // Ctrl+Click or Meta+Click - event.preventDefault(); - this.$emit('scan-ip', { ip: this.data.ip }); - } else { - // Normal click - this.copyIp(event); - } - } - // --- End new methods --- - } -}); - -/* ---------- Autocomplete ---------- */ -Vue.component('radius-autocomplete', { - props: { value: String, placeholder: String, wide: { type: Boolean, default: true } }, data() { return { q: this.value || '', open: false, items: {}, highlighted: -1, busy: false, mode: 'autocomplete', logoDropdownOpen: false, hasMoreResults: false }; }, watch: { value(v){ if (v !== this.q) { this.q = v; if (this.mode === 'autocomplete') this.debouncedFetch(); } } }, - template: `
Klicken Sie auf das Logo, um die Kundenbasis zu wechseln
Xinon LogoXINON (Suche)
ESTMK LogoESTMK (Eingabe)
`, - computed: { highlightedId(){ const k=Object.keys(this.items); return k[this.highlighted] || null; }, placeholderText() { return this.mode === 'autocomplete' ? (this.placeholder || 'Rechnungsadresse suchen') : 'Partner-Kundennummer eingeben'; } }, - created() { this.debouncedFetch = this.debounce(() => this.fetchItems(), 220); }, - methods: { - toggleLogoDropdown() { this.logoDropdownOpen = !this.logoDropdownOpen; if (this.logoDropdownOpen) this.open = false; }, - selectMode(m) { if (this.mode !== m) { this.mode = m; this.$emit('mode-change', m); this.clear(); } this.logoDropdownOpen = false; this.$nextTick(() => this.$refs.mainInput.focus()); }, - onInput() { this.$emit('input', this.q); if (this.mode === 'autocomplete') this.debouncedFetch(); }, - onEnter() { if (this.mode === 'autocomplete') this.chooseHighlighted(true); else this.$emit('enter'); }, - maybeOpen(){ this.open = true; if (this.q) this.debouncedFetch(); }, - deferClose(){ setTimeout(()=> { this.open = false; this.logoDropdownOpen = false; }, 150); }, - clear(){ this.q = ''; this.items={}; this.highlighted=-1; this.emitSelection('', ''); if (this.mode === 'autocomplete') { this.open = true; this.debouncedFetch(); } }, - move(d){ const k=Object.keys(this.items); if (!k.length) return; this.highlighted=(this.highlighted+d+k.length)%k.length; this.$nextTick(() => { const a = this.$refs.resultsList?.querySelector('.is-active'); if (a) a.scrollIntoView({ block: 'center', behavior: 'smooth' }); }); }, - chooseHighlighted(e){ const i=this.highlightedId; if (i) this.choose(i, this.items[i], e); else if (e) this.$emit('enter'); }, - choose(id, display, emitEnter){ const c=(display.match(/\[(\d+)\]/)||[])[1] || ''; this.emitSelection(c, display); this.open=false; if (emitEnter) this.$emit('enter'); }, - emitSelection(custnum, display){ this.$emit('select', { custnum, display }); this.$emit('input', display); this.$emit('change', display); }, - async fetchItems() { if (this.mode !== 'autocomplete' || !this.q || this.q.length < 2) { this.items = {}; this.hasMoreResults = false; return; } this.busy = true; try { const b = window.TT_CONFIG.BASE_PATH || ''; const r = await fetch(`${b}/Address/Api?do=findAddress&fibu_primary_account=1&q=${encodeURIComponent(this.q)}`); if (r.ok) { const j = await r.json(); const addresses = j?.result?.addresses || {}; if (addresses.more) { this.hasMoreResults = true; delete addresses.more; } else { this.hasMoreResults = false; } this.items = addresses; this.highlighted = 0; } else { this.items = {}; this.hasMoreResults = false; } } catch { this.items = {}; this.hasMoreResults = false; } this.busy = false; }, - debounce(fn, ms){ let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a), ms); }; } - } -}); - -/* ---------- Generic Modal ---------- */ -Vue.component('radius-modal', { - props: { show: Boolean, title: String, modalClass: String }, - template: ` - - - - `, - watch: { - show(isShown) { - if (isShown) { - this.$nextTick(() => { - // nodeType 1 is an Element node, this prevents errors if v-if renders a comment node. - if (this.$el && this.$el.nodeType === 1 && this.$el.parentNode !== document.body) { - document.body.appendChild(this.$el); - } - document.body.style.overflow = 'hidden'; - }); - } else { - document.body.style.overflow = ''; - } - } - }, - beforeDestroy() { - if (this.show && this.$el && this.$el.nodeType === 1 && this.$el.parentNode === document.body) { - document.body.removeChild(this.$el); - } - document.body.style.overflow = ''; - } -}); - +/* ===== Radius.js (Vue 3 + TT-Core) ===== */ /* ---------- Root View: ---------- */ -Vue.component('radius', { +const Radius = { + name: 'Radius', template: ` -
+
-
Radius
+
+
+ + Radius +
+ +
+ +
+

-
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
`, - data() { return { view: 'users', window: window, _initFlags: {} }; }, + data() { + return { + view: 'users', + window: window, + _initFlags: {} + }; + }, computed: { viewOptions() { - const o = [{ id: 'users', name: 'Benutzer', icon: 'fa-duotone fa-users' },{ id: 'free', name: 'Freie Benutzer', icon: 'fa-duotone fa-user-plus' },{ id: 'unused', name: 'Ungenutzte Benutzer', icon: 'fa-duotone fa-user-clock' }]; - if (window.TT_CONFIG.CAN_BILLING === '1') { o.push({ id: 'ont', name: 'ONT Parser', icon: 'fa-duotone fa-diagram-project' }, { id: 'ontReverse', name: 'ONT Reverse', icon: 'fa-duotone fa-arrows-rotate' }); } return o; + const options = [ + { id: 'users', name: 'Benutzer', icon: 'fa-duotone fa-users' }, + { id: 'free', name: 'Freie Benutzer', icon: 'fa-duotone fa-user-plus' }, + { id: 'unused', name: 'Ungenutzte Benutzer', icon: 'fa-duotone fa-user-clock' } + ]; + + if (window.TT_CONFIG.CAN_BILLING === '1') { + options.push( + { id: 'ont', name: 'ONT Parser', icon: 'fa-duotone fa-diagram-project' }, + { id: 'ontReverse', name: 'ONT Reverse', icon: 'fa-duotone fa-arrows-rotate' } + ); + } + + return options; } }, - mounted() { this.switchView(this.view); }, + mounted() { + this.switchView(this.view); + }, methods: { - switchView(v){ this.view=v; if(!this._initFlags||this._initFlags[v])return; let r=''; if(v==='free')r='freeView';else if(v==='unused')r='unusedView'; if(r){this.$nextTick(()=>{const c=this.$refs[r];if(c&&typeof c.initIfNeeded==='function'){c.initIfNeeded();this._initFlags[v]=true;}});}} + switchView(v) { + this.view = v; + + if (!this._initFlags || this._initFlags[v]) return; + + let refName = ''; + if (v === 'free') refName = 'freeView'; + else if (v === 'unused') refName = 'unusedView'; + + if (refName) { + this.$nextTick(() => { + const childComponent = this.$refs[refName]; + if (childComponent && typeof childComponent.initIfNeeded === 'function') { + childComponent.initIfNeeded(); + this._initFlags[v] = true; + } + }); + } + } } -}); +}; + +// Register component with Vue 3 app +if (window.VueApp) { + window.VueApp.component('radius', Radius); +} diff --git a/public/js/pages/Radius/RadiusFreeUsers.js b/public/js/pages/Radius/RadiusFreeUsers.js index 9c4a9ffa9..47748dd3b 100644 --- a/public/js/pages/Radius/RadiusFreeUsers.js +++ b/public/js/pages/Radius/RadiusFreeUsers.js @@ -1,48 +1,159 @@ -/* ===== RadiusFreeUsers.js ===== */ -Vue.component('radius-free-users', { +/* ===== RadiusFreeUsers.js (Vue 3 + TT-Core) ===== */ + +const RadiusFreeUsers = { + name: 'RadiusFreeUsers', template: ` -
-
-
+
+
+
Freie NAT Benutzer {{ filteredNat.length }} - +
- - - + + + - -
{{ filteredNat.length }} Treffer gefunden
+ +
+ {{ filteredNat.length }} Treffer gefunden +
-
+
Freie STF Benutzer {{ filteredStf.length }} - +
- - - + + + - -
{{ filteredStf.length }} Treffer gefunden
+ +
+ {{ filteredStf.length }} Treffer gefunden +
`, - data: () => ({ nat: [], stf: [], loadingNat: false, loadingStf: false, _initialized: false }), - computed: { filteredNat() { return this.nat.filter(this.isTrulyFree); }, filteredStf() { return this.stf.filter(this.isTrulyFree); } }, + data: () => ({ + nat: [], + stf: [], + loadingNat: false, + loadingStf: false, + _initialized: false + }), + computed: { + filteredNat() { + return this.nat.filter(this.isTrulyFree); + }, + filteredStf() { + return this.stf.filter(this.isTrulyFree); + } + }, methods: { - initIfNeeded(){ if (this._initialized) return; this._initialized = true; this.reloadNat(); this.reloadStf(); }, - isTrulyFree(user) { return !/frei[a-z]/.test((user.Info || '').toLowerCase()); }, - normalizeUsers(arr){ if (!Array.isArray(arr)) return []; return arr.map(u => ({ Username: (u.Username || u.username || '').trim(), Info: (u.Info || u.info || '').toString().replace(/\s+$/,'') })).filter(u => u.Username); }, - async reloadNat() { this.nat = []; this.loadingNat = true; try { const r = await fetch(window.TT_CONFIG.BASE_PATH + '/Radius/proxyUnsecureHTTPRequestToRadius?action2=free_user&filter=nat'); this.nat = this.normalizeUsers(r.ok ? (await r.json()).users : []); } catch { this.nat = []; } this.loadingNat = false; }, - async reloadStf() { this.stf = []; this.loadingStf = true; try { const r = await fetch(window.TT_CONFIG.BASE_PATH + '/Radius/proxyUnsecureHTTPRequestToRadius?action2=free_user&filter=stf'); this.stf = this.normalizeUsers(r.ok ? (await r.json()).users : []); } catch { this.stf = []; } this.loadingStf = false; } + initIfNeeded() { + if (this._initialized) return; + this._initialized = true; + this.reloadNat(); + this.reloadStf(); + }, + isTrulyFree(user) { + return !/frei[a-z]/.test((user.Info || '').toLowerCase()); + }, + normalizeUsers(arr) { + if (!Array.isArray(arr)) return []; + return arr.map(u => ({ + Username: (u.Username || u.username || '').trim(), + Info: (u.Info || u.info || '').toString().replace(/\s+$/, '') + })).filter(u => u.Username); + }, + async reloadNat() { + this.nat = []; + this.loadingNat = true; + try { + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { action2: 'free_user', filter: 'nat' } + }); + this.nat = this.normalizeUsers(data?.users || []); + } catch (error) { + this.nat = []; + } + this.loadingNat = false; + }, + async reloadStf() { + this.stf = []; + this.loadingStf = true; + try { + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { action2: 'free_user', filter: 'stf' } + }); + this.stf = this.normalizeUsers(data?.users || []); + } catch (error) { + this.stf = []; + } + this.loadingStf = false; + } } -}); \ No newline at end of file +}; + +// Register component with Vue 3 app +if (window.VueApp) { + window.VueApp.component('radius-free-users', RadiusFreeUsers); +} diff --git a/public/js/pages/Radius/RadiusNetworkNode.js b/public/js/pages/Radius/RadiusNetworkNode.js new file mode 100644 index 000000000..187a2412a --- /dev/null +++ b/public/js/pages/Radius/RadiusNetworkNode.js @@ -0,0 +1,67 @@ +const RadiusNetworkNode = { + name: 'RadiusNetworkNode', + props: { + device: Object + }, + template: ` +
+
+
+ +
+
+
+
+
{{ device.name }}
+
+ {{ device.ipv4.ip }} +
+
+ {{ device.mac }} +
+
{{ device.vendor }}
+
+ {{ details }} +
+
+
+
+
+ +
+
+
+ `, + computed: { + iconClass() { + if (this.device.model === 'fbox') return 'fa-duotone fa-router'; + if ((this.device.name || '').toLowerCase().includes('repeater')) return 'fa-duotone fa-wifi-exclamation'; + if (this.device.type === 'wlan') return 'fa-duotone fa-mobile-screen'; + return 'fa-duotone fa-desktop'; + }, + connectionType() { + if (this.device.type === 'wlan') return 'wlan'; + if (this.device.type === 'ethernet') return 'ethernet'; + return null; + }, + nodeClass() { + return { + 'is-router': this.device.model === 'fbox', + 'is-repeater': (this.device.name || '').toLowerCase().includes('repeater'), + 'is-offline': this.device.state && this.device.state.class !== 'globe_online' && this.device.state.class !== 'led_green' + } + }, + details() { + if (this.device.properties && this.device.properties.length > 0) { + const props = this.device.properties.filter(p => p.txt && p.txt !== 'Mesh'); + if (props.length > 0) return props[0].txt; + } + if (this.device.port && this.device.port !== 'WLAN') return this.device.port; + return null; + } + } +}; + +if (window.VueApp) { + VueApp.component('radius-network-node', RadiusNetworkNode); +} \ No newline at end of file diff --git a/public/js/pages/Radius/RadiusOntFinder.js b/public/js/pages/Radius/RadiusOntFinder.js index 17a2de939..892997650 100644 --- a/public/js/pages/Radius/RadiusOntFinder.js +++ b/public/js/pages/Radius/RadiusOntFinder.js @@ -1,29 +1,203 @@ -/* ===== RadiusOntFinder.js ===== */ -Vue.component('radius-ont-finder', { +/* ===== RadiusOntFinder.js (Vue 3 + TT-Core) ===== */ + +const RadiusOntFinder = { + name: 'RadiusOntFinder', template: ` -
+
-
Schritt 1 · Excel (XLSX) Upload

Datei muss die Spalte Serial enthalten. Optional MAC.

-
{{ uploadError }}
+
+
Schritt 1 · Excel (XLSX) Upload
+

Datei muss die Spalte Serial enthalten. Optional MAC.

+
+ +
{{ uploadError }}
-
Ergebnisse
+
+
Ergebnisse
+
+ + +
+
- - - - - -
{{ processedData.length }} Zeilen verarbeitet
+ + + + + +
+ {{ processedData.length }} Zeilen verarbeitet +
`, - data: () => ({ step: 1, parsedData: [], processedData: [], originalHeaders: [], loading: false, progress: 0, currentRow: 0, totalRows: 0, currentSerial: '', uploadError: null, serialColumnName: 'Serial', macColumnName: 'MAC', fetchedKeys: { username: 'fetched_username', customerNumber: 'fetched_customerNumber', customerName: 'fetched_customerName', info: 'fetched_info' }, apiBasePath: window.TT_CONFIG?.BASE_PATH }), + data: () => ({ + step: 1, + parsedData: [], + processedData: [], + originalHeaders: [], + loading: false, + progress: 0, + currentRow: 0, + totalRows: 0, + currentSerial: '', + uploadError: null, + serialColumnName: 'Serial', + macColumnName: 'MAC', + fetchedKeys: { + username: 'fetched_username', + customerNumber: 'fetched_customerNumber', + customerName: 'fetched_customerName', + info: 'fetched_info' + }, + apiBasePath: window.TT_CONFIG?.BASE_PATH + }), methods: { - resetComponent(){ Object.assign(this.$data, this.$options.data.call(this)); const i=this.$el.querySelector('input[type="file"]'); if (i) i.value=''; }, - async readXlsx(file){ this.uploadError=null; try{ await window.RadiusUtils.loadScript('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js'); const arr = await new Promise((res,rej)=>{ const r=new FileReader(); r.onload=e=>res(new Uint8Array(e.target.result)); r.onerror=()=>rej(new Error('Fehler beim Lesen.')); r.readAsArrayBuffer(file); }); const wb = XLSX.read(arr, {type:'array'}); const ws = wb.Sheets[wb.SheetNames[0]]; this.parsedData = XLSX.utils.sheet_to_json(ws, {defval:''}); if (!this.parsedData.length) throw new Error('Die Datei ist leer.'); this.originalHeaders = Object.keys(this.parsedData[0]); if (!this.originalHeaders.includes(this.serialColumnName)) throw new Error(`Erforderliche Spalte '${this.serialColumnName}' nicht gefunden.`); this.startProcessing(); } catch(e){ this.uploadError=e.message; this.step=1; } }, - async startProcessing(){ this.step = 2; this.loading = true; this.totalRows=this.parsedData.length; this.processedData=[]; const setRow = (row, msg, data={})=>{ const d={username:`N/A - ${msg}`,customerNumber:'N/A',customerName:'N/A',info:'N/A'}; Object.keys(this.fetchedKeys).forEach(k=>row[this.fetchedKeys[k]]=data[k]||d[k]); }; for (const [i,row] of this.parsedData.entries()){ this.currentRow=i; const out={...row}; const sn=(''+(row[this.serialColumnName]||'')).trim(); this.currentSerial=`SN: ${sn||'—'}`; let found=false; if (sn){ try{ const r=await fetch(`${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?ont_sn=${encodeURIComponent(sn)}`); if(r.ok){ const j=await r.json(); if(Array.isArray(j)&&j.length>0){ setRow(out,'',j[0]); found=true; }}}catch{} } if (!found && this.originalHeaders.includes(this.macColumnName)){ const macRaw=(''+(row[this.macColumnName]||'')).trim(); if(macRaw&&macRaw.length===12){ const mac=macRaw.toUpperCase().match(/.{1,2}/g).join(':'); try{ const s=await fetch(`${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?action2=find_by_current_session&mac=${encodeURIComponent(mac)}`); if(s.ok){ const ses=await s.json(); if(Array.isArray(ses)&&ses.length>0){ const u=await fetch(`${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?username=${encodeURIComponent(ses[0])}&info=&custnum=`); if(u.ok){ const d=await u.json(); if(Array.isArray(d)&&d.length>0) {setRow(out,'',d[0]); found=true;}}}}}catch{}}} if(!found) setRow(out,'Keinen Benutzer gefunden'); this.processedData.push(out); this.progress=((i+1)/this.totalRows)*100; if ((i+1)%20===0) await new Promise(r=>setTimeout(r,20)); } this.loading=false; this.currentSerial=''; }, - downloadResults(){ if (!this.processedData.length) return; try{ const data=this.processedData.map(r=>{ const o={}; this.originalHeaders.forEach(h=>o[h]=r[h]); Object.keys(this.fetchedKeys).forEach(k=>{const K=k.charAt(0).toUpperCase()+k.slice(1).replace('Number','nummer').replace('Name','name'); o[K]=r[this.fetchedKeys[k]];}); return o; }); const ws=XLSX.utils.json_to_sheet(data); const wb=XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb,ws,'ONT_Finder_Results'); XLSX.writeFile(wb, `ont_finder_results_${new Date().toISOString().replace(/[-:.]/g,'').slice(0,14)}.xlsx`); } catch { if(window.notify) window.notify('error', 'Fehler beim Erstellen der Excel-Datei.'); } } + resetComponent() { + Object.assign(this.$data, this.$options.data.call(this)); + const i = this.$el.querySelector('input[type="file"]'); + if (i) i.value = ''; + }, + async readXlsx(file) { + this.uploadError = null; + try { + await window.TT_CORE.loadScript('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js'); + const arr = await new Promise((res, rej) => { + const r = new FileReader(); + r.onload = e => res(new Uint8Array(e.target.result)); + r.onerror = () => rej(new Error('Fehler beim Lesen.')); + r.readAsArrayBuffer(file); + }); + const wb = XLSX.read(arr, {type: 'array'}); + const ws = wb.Sheets[wb.SheetNames[0]]; + this.parsedData = XLSX.utils.sheet_to_json(ws, {defval: ''}); + if (!this.parsedData.length) throw new Error('Die Datei ist leer.'); + this.originalHeaders = Object.keys(this.parsedData[0]); + if (!this.originalHeaders.includes(this.serialColumnName)) + throw new Error(`Erforderliche Spalte '${this.serialColumnName}' nicht gefunden.`); + this.startProcessing(); + } catch (e) { + this.uploadError = e.message; + this.step = 1; + } + }, + async startProcessing() { + this.step = 2; + this.loading = true; + this.totalRows = this.parsedData.length; + this.processedData = []; + const setRow = (row, msg, data = {}) => { + const d = { + username: `N/A - ${msg}`, + customerNumber: 'N/A', + customerName: 'N/A', + info: 'N/A' + }; + Object.keys(this.fetchedKeys).forEach(k => row[this.fetchedKeys[k]] = data[k] || d[k]); + }; + for (const [i, row] of this.parsedData.entries()) { + this.currentRow = i; + const out = {...row}; + const sn = ('' + (row[this.serialColumnName] || '')).trim(); + this.currentSerial = `SN: ${sn || '—'}`; + let found = false; + if (sn) { + try { + const { data } = await axios.get(`${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { ont_sn: sn } + }); + if (Array.isArray(data) && data.length > 0) { + setRow(out, '', data[0]); + found = true; + } + } catch { + } + } + if (!found && this.originalHeaders.includes(this.macColumnName)) { + const macRaw = ('' + (row[this.macColumnName] || '')).trim(); + if (macRaw && macRaw.length === 12) { + const mac = macRaw.toUpperCase().match(/.{1,2}/g).join(':'); + try { + const { data: ses } = await axios.get(`${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { action2: 'find_by_current_session', mac } + }); + if (Array.isArray(ses) && ses.length > 0) { + const { data: d } = await axios.get(`${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { username: ses[0], info: '', custnum: '' } + }); + if (Array.isArray(d) && d.length > 0) { + setRow(out, '', d[0]); + found = true; + } + } + } catch { + } + } + } + if (!found) setRow(out, 'Keinen Benutzer gefunden'); + this.processedData.push(out); + this.progress = ((i + 1) / this.totalRows) * 100; + if ((i + 1) % 20 === 0) await new Promise(r => setTimeout(r, 20)); + } + this.loading = false; + this.currentSerial = ''; + }, + downloadResults() { + if (!this.processedData.length) return; + try { + const data = this.processedData.map(r => { + const o = {}; + this.originalHeaders.forEach(h => o[h] = r[h]); + Object.keys(this.fetchedKeys).forEach(k => { + const K = k.charAt(0).toUpperCase() + k.slice(1).replace('Number', 'nummer').replace('Name', 'name'); + o[K] = r[this.fetchedKeys[k]]; + }); + return o; + }); + const ws = XLSX.utils.json_to_sheet(data); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'ONT_Finder_Results'); + XLSX.writeFile(wb, `ont_finder_results_${new Date().toISOString().replace(/[-:.]/g, '').slice(0, 14)}.xlsx`); + } catch { + if (window.notify) window.notify('error', 'Fehler beim Erstellen der Excel-Datei.'); + } + } } -}); \ No newline at end of file +}; + +// Register component with Vue 3 app +if (window.VueApp) { + window.VueApp.component('radius-ont-finder', RadiusOntFinder); +} diff --git a/public/js/pages/Radius/RadiusOntParser.js b/public/js/pages/Radius/RadiusOntParser.js index cc35ab919..ff072c480 100644 --- a/public/js/pages/Radius/RadiusOntParser.js +++ b/public/js/pages/Radius/RadiusOntParser.js @@ -1,36 +1,185 @@ -/* ===== RadiusOntParser.js ===== */ -Vue.component('radius-ont-parser', { +/* ===== RadiusOntParser.js (Vue 3 + TT-Core) ===== */ + +const RadiusOntParser = { + name: 'RadiusOntParser', template: ` -
+
-
Schritt 1 · Excel (XLSX) Upload
Laden Sie eine XLSX-Datei mit Ihren Kundendaten.
- +
+
Schritt 1 · Excel (XLSX) Upload
+
Laden Sie eine XLSX-Datei mit Ihren Kundendaten.
+
+
-
Schritt 2 · Spaltenzuordnung
-
-
+
+
Schritt 2 · Spaltenzuordnung
+
+
+
+ +
+ +
+
+
+
+ + +
-
Schritt 3 · Ergebnisse
+
+
Schritt 3 · Ergebnisse
+
+ + + +
+
- - - - - - - -
{{ processedData.length }} Zeilen verarbeitet
+ + + + + +
+ {{ processedData.length }} Zeilen verarbeitet +
`, - data: () => ({ step: 1, headers: [], parsedData: [], processedData: [], selectedColumns: { kundennummer: 'crmPartner', anschlussstrasse: 'AnlStrasse', anschlussplz: 'AnlPlz', anschlusscity: 'AnlOrt' }, requiredFields: [ { key: 'kundennummer', label: 'Kundennummer' }, { key: 'anschlussstrasse', label: 'Anschlussstraße' }, { key: 'anschlussplz', label: 'Anschluss PLZ' }, { key: 'anschlusscity', label: 'Anschluss City' } ], loading: false, progress: 0, currentRow: 0, totalRows: 0, currentCustomerNumber: '' }), + data: () => ({ + step: 1, + headers: [], + parsedData: [], + processedData: [], + selectedColumns: { + kundennummer: 'crmPartner', + anschlussstrasse: 'AnlStrasse', + anschlussplz: 'AnlPlz', + anschlusscity: 'AnlOrt' + }, + requiredFields: [ + { key: 'kundennummer', label: 'Kundennummer' }, + { key: 'anschlussstrasse', label: 'Anschlussstraße' }, + { key: 'anschlussplz', label: 'Anschluss PLZ' }, + { key: 'anschlusscity', label: 'Anschluss City' } + ], + loading: false, + progress: 0, + currentRow: 0, + totalRows: 0, + currentCustomerNumber: '' + }), methods: { - async readXlsx(file){ await window.RadiusUtils.loadScript('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js'); const fr = new FileReader(); fr.onload = (e)=>{ const wb = XLSX.read(new Uint8Array(e.target.result), { type: 'array' }); this.parsedData = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); this.headers = Object.keys(this.parsedData[0] || {}); this.step = 2; }; fr.readAsArrayBuffer(file); }, - async startProcessing(){ this.step = 3; this.loading = true; this.totalRows = this.parsedData.length; this.processedData = []; this.currentRow = 0; const p = []; const b = window.TT_CONFIG.BASE_PATH; loop: for (let i=0; isetTimeout(r,20)); } this.loading=false; this.processedData = p; }, - downloadResults(){ const ws = XLSX.utils.json_to_sheet(this.processedData); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Results'); XLSX.writeFile(wb, 'results.xlsx'); }, - resetLocal(){ Object.assign(this.$data, this.$options.data.call(this)); } + async readXlsx(file) { + await window.TT_CORE.loadScript('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js'); + const fr = new FileReader(); + fr.onload = (e) => { + const wb = XLSX.read(new Uint8Array(e.target.result), { type: 'array' }); + this.parsedData = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); + this.headers = Object.keys(this.parsedData[0] || {}); + this.step = 2; + }; + fr.readAsArrayBuffer(file); + }, + async startProcessing() { + this.step = 3; + this.loading = true; + this.totalRows = this.parsedData.length; + this.processedData = []; + this.currentRow = 0; + const p = []; + const b = window.TT_CONFIG.BASE_PATH; + loop: for (let i = 0; i < this.parsedData.length; i++) { + this.currentRow = i; + this.progress = ((i + 1) / this.totalRows) * 100; + const row = { ...this.parsedData[i] }; + this.currentCustomerNumber = row[this.selectedColumns.kundennummer] || ''; + try { + const { data: users } = await axios.get(`${b}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { custnume: row[this.selectedColumns.kundennummer] } + }); + if (users.length === 0) { + row.ont_sn = 'N/A - Kein Benutzer'; + } else if (users.length === 1) { + const { data: d } = await axios.get(`${b}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { skipAdditional: 'true', action2: 'fetchRadacct', username: users[0].username } + }); + row.ont_sn = d.ont_sn || 'N/A - Keine ONT SN'; + } else { + const [s, pl, c] = [row[this.selectedColumns.anschlussstrasse], row[this.selectedColumns.anschlussplz], row[this.selectedColumns.anschlusscity]]; + for (let u of users) { + if (window.TT_CORE.validateData(s, pl, c, u.info || users[0].info || '')) { + const { data: d } = await axios.get(`${b}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { skipAdditional: 'true', action2: 'fetchRadacct', username: u.username } + }); + row.ont_sn = d.ont_sn || 'N/A - Keine ONT SN'; + p.push(row); + continue loop; + } + } + row.ont_sn = 'N/A - Anschluss nicht zugeordnet'; + } + } catch { + row.ont_sn = 'N/A - Fehler'; + } + p.push(row); + if ((i + 1) % 20 === 0) await new Promise(r => setTimeout(r, 20)); + } + this.loading = false; + this.processedData = p; + }, + downloadResults() { + const ws = XLSX.utils.json_to_sheet(this.processedData); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Results'); + XLSX.writeFile(wb, 'results.xlsx'); + }, + resetLocal() { + Object.assign(this.$data, this.$options.data.call(this)); + } } -}); \ No newline at end of file +}; + +// Register component with Vue 3 app +if (window.VueApp) { + window.VueApp.component('radius-ont-parser', RadiusOntParser); +} diff --git a/public/js/pages/Radius/RadiusRadacctModal.js b/public/js/pages/Radius/RadiusRadacctModal.js new file mode 100644 index 000000000..3dc20657e --- /dev/null +++ b/public/js/pages/Radius/RadiusRadacctModal.js @@ -0,0 +1,85 @@ +const RadiusRadacctModal = { + name: 'RadiusRadacctModal', + props: { + show: Boolean, + username: String + }, + template: ` + +
+
Status +
+
{{ radacctData.online ? 'Online' : 'Offline' }} +
+
+
+
+ +
IP +
+
+ {{ radacctData.ip }} + + +
+
+
+
+ +
Username + +
+ + + +
+
+ `, + data: () => ({ + radacctData: null + }), + watch: { + show(val) { + if (val && this.username) { + this.fetchRadacctData(); + } else { + this.radacctData = null; + } + } + }, + methods: { + async fetchRadacctData() { + this.radacctData = null; + try { + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { action2: 'fetchRadacct', username: this.username } + }); + this.radacctData = data; + } catch (error) { + console.error(error); + this.radacctData = {}; + } + } + } +}; + +if (window.VueApp) { + window.VueApp.component('RadiusRadacctModal', RadiusRadacctModal); +} \ No newline at end of file diff --git a/public/js/pages/Radius/RadiusRouterManager.js b/public/js/pages/Radius/RadiusRouterManager.js new file mode 100644 index 000000000..6d0d6c87e --- /dev/null +++ b/public/js/pages/Radius/RadiusRouterManager.js @@ -0,0 +1,486 @@ +const RadiusRouterManager = { + name: 'RadiusRouterManager', + props: { + show: Boolean, + userItem: Object + }, + template: ` +
+ + + + + + + + + + +
+
+
Gesendet{{ pingResult.packetsTransmitted }}
+
Empfangen{{ pingResult.packetsReceived }}
+
Verlust{{ pingResult.packetLoss }}%
+
Min / Avg / Max{{ pingResult.min }} / {{ pingResult.avg }} / {{ pingResult.max }} ms
+
+
+
Kein Ergebnis.
+
+ + + + +
+
+ + + + + + + + + + + + + + + + + +
#BandbreiteÜbertragenPakete
{{ idx + 1 }}{{ row.bpsFormatted }}{{ row.bytesFormatted }}{{ row.packets }}
+
+
+
+ Aktualisiere... +
+
+ Abgeschlossen +
+
+
+ + + + +
+
+ Konfiguration erfolgreich abgeschlossen. +
+
+
+ Remote Link + +
+
+ Username +
+ {{ remoteAccessResult.username }} + +
+
+
+ Password +
+ {{ remoteAccessResult.password }} + +
+
+
+
+
Ein Fehler ist aufgetreten.
+
+ + + + +
+
+ + +
+
+
Keine Daten verfügbar.
+
+ +
+ `, + data: () => ({ + routerLoading: false, + routerActionLoading: false, + routerDevice: null, + + // Sub-Modal States + showPingModal: false, + pingResult: null, + + showSpeedtestModal: false, + speedtestLoading: false, + speedtestResult: null, + speedtestHistory: [], + speedtestHasStarted: false, + + showRemoteAccessModal: false, + remoteAccessLoading: false, + remoteAccessResult: null, + remoteAccessStep: '', + + showNetworkStructureModal: false, + networkStructureLoading: false, + rootDevice: null + }), + watch: { + show: { + handler(val) { + if (val && this.userItem) { + this.loadRouterData(); + } + }, + immediate: true + }, + userItem(val) { + if (val && this.show) { + this.loadRouterData(); + } + } + }, + methods: { + async loadRouterData() { + this.routerLoading = true; + this.routerDevice = null; + this.pingResult = null; + this.speedtestResult = null; + this.speedtestLoading = false; + + try { + const { data: radacct } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { action2: 'fetchRadacct', username: this.userItem.username } + }); + + if (radacct?.ip) { + const { data: deviceData } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsGetDeviceByIp`, { + params: { ip: radacct.ip } + }); + + if (deviceData?.success) { + this.routerDevice = deviceData; + } + } + } catch (error) { + console.error('Error fetching router:', error); + window.notify('error', 'Fehler beim Laden des Routers'); + } + this.routerLoading = false; + }, + async rebootRouter() { + if (!this.routerDevice || !this.routerDevice.deviceId) return; + if (!confirm('Möchten Sie den Router wirklich neu starten?')) return; + + this.routerActionLoading = true; + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsRebootDevice`, { + deviceId: this.routerDevice.deviceId + }); + + if (data.success) { + window.notify('success', 'Router-Neustart gestartet'); + } else { + window.notify('error', data.message || 'Fehler beim Neustart'); + } + } catch (error) { + console.error('Error rebooting router:', error); + window.notify('error', 'Fehler beim Neustarten des Routers'); + } + this.routerActionLoading = false; + }, + async pingRouter() { + if (!this.routerDevice) return; + const pingIp = this.routerDevice.managementIp || this.routerDevice.ip; + if (!pingIp) return; + + this.showPingModal = true; + this.routerActionLoading = true; + this.pingResult = null; + try { + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsPing`, { + params: { ip: pingIp } + }); + + if (data.success && data.result) { + this.pingResult = data.result; + window.notify('success', 'Ping erfolgreich'); + } else { + window.notify('error', 'Ping fehlgeschlagen'); + } + } catch (error) { + console.error('Error pinging router:', error); + window.notify('error', 'Fehler beim Pingen des Routers'); + } + this.routerActionLoading = false; + }, + async setParameterValues(parameters) { + if (!this.routerDevice || !this.routerDevice.deviceId || !parameters) return false; + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsSetParameters`, { + deviceId: this.routerDevice.deviceId, + parameters: parameters + }); + return data.success; + } catch (error) { + console.error('Error setting parameters:', error); + return false; + } + }, + async runSpeedtest() { + if (!this.routerDevice || !this.routerDevice.deviceId) return; + this.showSpeedtestModal = true; + this.speedtestLoading = true; + this.speedtestResult = null; + this.speedtestHistory = []; + this.speedtestHasStarted = false; + + try { + const params = { + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Start': 1, + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.StartBidirect': 1, + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.WANAccess': true + }; + + if (!await this.setParameterValues(params)) { + throw new Error("Konnte Speedtest-Parameter nicht setzen"); + } + + const ip = this.routerDevice.ip; + if (!ip) throw new Error("Keine IP-Adresse gefunden"); + + await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsRunSpeedtest`, { ip }); + + this.pollSpeedtestResult(); + + } catch (e) { + window.notify('error', e.message); + this.speedtestLoading = false; + } + }, + async pollSpeedtestResult() { + let attempts = 0; + const maxAttempts = 240; + const resultParam = 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result'; + + const poll = async () => { + if (!this.showSpeedtestModal) return; + if (attempts >= maxAttempts) { + this.speedtestLoading = false; + window.notify('error', 'Speedtest Zeitüberschreitung'); + return; + } + attempts++; + + try { + await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsGetParameters`, { + deviceId: this.routerDevice.deviceId, + parameters: [resultParam] + }); + + setTimeout(async () => { + try { + const val = await this.fetchDeviceParameterValue(resultParam); + if (val && typeof val === 'string' && val.includes("BPS")) { + const parsed = this.parseSpeedtestResult(val); + if (parsed) { + this.speedtestHistory.push(parsed); + this.$nextTick(() => { + if (this.$refs.speedtestBottom) { + this.$refs.speedtestBottom.scrollIntoView({ behavior: 'smooth' }); + } + }); + + if (parsed.bps > 0) this.speedtestHasStarted = true; + + if (this.speedtestHasStarted && parsed.bps === 0) { + this.speedtestLoading = false; + window.notify('success', 'Speedtest abgeschlossen'); + return; + } + } + } + } catch(e) { console.error(e); } + + if (this.speedtestLoading) setTimeout(poll, 500); + }, 500); + + } catch (e) { + console.error(e); + if (this.speedtestLoading) setTimeout(poll, 500); + } + }; + poll(); + }, + async fetchDeviceParameterValue(paramName) { + if (!this.routerDevice || !this.routerDevice.deviceId) return null; + try { + const { data: deviceInfo } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsGetDeviceInfo`, { + params: { deviceId: this.routerDevice.deviceId } + }); + + if (deviceInfo?.success && deviceInfo?.fullData) { + const paramData = deviceInfo.fullData[paramName]; + if (paramData?.value?.[0]) { + return paramData.value[0]; + } + } + } catch (error) { + console.error('Error fetching parameter value:', error); + } + return null; + }, + formatBits(bps) { + if (!bps) return '0 Mbit/s'; + const mbits = bps / 1000000; + return mbits.toFixed(2) + ' Mbit/s'; + }, + parseSpeedtestResult(raw) { + try { + const bpsMatch = raw.match(/BPS\s+(\d+)/); + const bytesMatch = raw.match(/Bytes\s+(\d+)/); + const packetsMatch = raw.match(/Packets\s+(\d+)/); + + if (bpsMatch) { + const bps = parseInt(bpsMatch[1]); + const bytes = bytesMatch ? parseInt(bytesMatch[1]) : 0; + const packets = packetsMatch ? parseInt(packetsMatch[1]) : 0; + + return { + raw: raw, + bps: bps, + bpsFormatted: this.formatBits(bps), + bytes: bytes, + bytesFormatted: window.TT_CORE.formatBytes(bytes), + packets: packets + }; + } + } catch (e) { + console.error("Error parsing speedtest result", e); + } + return null; + }, + async runRemoteAccess() { + if (!this.routerDevice || !this.routerDevice.deviceId) return; + this.showRemoteAccessModal = true; + this.remoteAccessLoading = true; + this.remoteAccessStep = 'Konfiguriere Zugriff...'; + this.remoteAccessResult = null; + + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsRemoteAccess`, { + deviceId: this.routerDevice.deviceId + }); + + if (data.success) { + this.remoteAccessResult = data; + } else { + throw new Error(data.message || "Unbekannter Fehler"); + } + } catch (error) { + window.notify('error', error.response?.data?.message || error.message || 'Fehler bei Remote Access'); + } finally { + this.remoteAccessLoading = false; + } + }, + async openNetworkStructure() { + if (!this.routerDevice || !this.routerDevice.deviceId) return; + this.showNetworkStructureModal = true; + this.networkStructureLoading = true; + this.rootDevice = null; + + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsNetworkStructure`, { + deviceId: this.routerDevice.deviceId + }); + + if (data.root) { + this.rootDevice = data.root; + } + } catch (error) { + console.error(error); + window.notify('error', 'Fehler beim Laden der Netzwerkstruktur'); + } finally { + this.networkStructureLoading = false; + } + } + } +}; + +if (window.VueApp) { + window.VueApp.component('radius-router-manager', RadiusRouterManager); +} \ No newline at end of file diff --git a/public/js/pages/Radius/RadiusTransferModal.js b/public/js/pages/Radius/RadiusTransferModal.js new file mode 100644 index 000000000..1d7e62d22 --- /dev/null +++ b/public/js/pages/Radius/RadiusTransferModal.js @@ -0,0 +1,441 @@ +const RadiusTransferModal = { + name: 'RadiusTransferModal', + props: { + show: Boolean, + username: String + }, + template: ` + + + + + +
+
+ +
+ + +
+

+ Bitte geben Sie eine gültige E-Mail-Adresse ein. +

+
+
+ + +
+
+
+
+ `, + data: () => ({ + window: window, + transferInitialLoading: false, + transferMonthlyLoading: false, + transferYear: new Date().getFullYear(), + transferMonth: new Date().getMonth() + 1, + transferYearlyData: null, + transferMonthlyData: null, + transferChartInstance: null, + showYearDropdown: false, + + // Email Logic + showEmailModal: false, + isSendingEmail: false, + recipientEmail: '' + }), + computed: { + availableYears() { + const c = new Date().getFullYear(), s = 2021; + if (s > c) return [c]; + return Array.from({length: c - s + 1}, (_, i) => c - i); + }, + allMonths() { + return Array.from({length: 12}, (_, i) => ({ + month: i + 1, + name: new Date(2000, i, 1).toLocaleString('de-DE', {month: 'short'}) + })); + }, + isValidEmail() { + if (!this.recipientEmail) return false; + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.recipientEmail); + } + }, + watch: { + show(val) { + if (val && this.username) { + this.transferYear = new Date().getFullYear(); + this.transferMonth = new Date().getMonth() + 1; + this.fetchTransferYearData(); + } else { + // Cleanup + this.close(); + } + } + }, + methods: { + close() { + this.$emit('close'); + this.transferYearlyData = null; + this.transferMonthlyData = null; + this.showYearDropdown = false; + this.showEmailModal = false; + this.recipientEmail = ''; + this.isSendingEmail = false; + if (this.transferChartInstance) { + this.transferChartInstance.destroy(); + this.transferChartInstance = null; + } + }, + async fetchTransferYearData() { + this.transferInitialLoading = true; + this.transferYearlyData = null; + try { + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { + action2: 'transferStatistic', + username: this.username, + year: this.transferYear, + month: 0 + } + }); + if (data && data.monthlySummary) { + this.transferYearlyData = data; + const last = [...data.monthlySummary].reverse().find(m => m.grandTotalBytes > 0); + this.transferMonth = last ? last.month : new Date().getMonth() + 1; + await this.fetchTransferMonthData(); + } else { + this.transferYearlyData = null; + } + } catch (e) { + console.error(e); + this.transferYearlyData = null; + } + this.transferInitialLoading = false; + }, + async fetchTransferMonthData() { + this.transferMonthlyLoading = true; + this.transferMonthlyData = null; + if (this.transferChartInstance) this.transferChartInstance.destroy(); + try { + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, { + params: { + action2: 'transferStatistic', + username: this.username, + year: this.transferYear, + month: this.transferMonth + } + }); + this.transferMonthlyData = data || null; + } catch (e) { + console.error(e); + this.transferMonthlyData = null; + } + this.transferMonthlyLoading = false; + this.$nextTick(() => { + if (this.show) this.renderTransferChart(); + }); + }, + isMonthDisabled(month) { + if (this.transferInitialLoading || this.transferMonthlyLoading) return true; + if (!this.transferYearlyData?.monthlySummary) return true; + const m = this.transferYearlyData.monthlySummary.find(m => m.month === month); + return !m || m.grandTotalBytes === 0; + }, + selectYear(year) { + this.showYearDropdown = false; + if (this.transferYear !== year) this.changeTransferYear(year); + }, + async changeTransferYear(year) { + this.transferYear = year; + await this.fetchTransferYearData(); + }, + async changeTransferMonth(month) { + this.transferMonth = month; + await this.fetchTransferMonthData(); + }, + prepareEmailModal() { + if (this.transferInitialLoading || !this.transferYearlyData || !this.transferYearlyData.yearlySummary || this.transferYearlyData.yearlySummary.grandTotalBytes === 0) return; + this.recipientEmail = ''; + this.showEmailModal = true; + }, + async sendTransferEmail() { + if (!this.transferMonthlyData || !this.transferChartInstance || !this.isValidEmail) return; + this.isSendingEmail = true; + try { + const chartImageBase64 = this.transferChartInstance.toBase64Image(); + const payload = { + username: this.username, + year: this.transferYear, + month: this.transferMonth, + monthlySummary: this.transferMonthlyData.summary, + monthlyDetails: this.transferMonthlyData.details, + chartImage: chartImageBase64, + recipient: this.recipientEmail + }; + await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/sendCustomerEmail`, payload); + window.notify('success', 'E-Mail wurde erfolgreich versendet.'); + this.showEmailModal = false; + } catch (e) { + console.error("Failed to send transfer email:", e); + window.notify('error', 'Fehler beim Senden der E-Mail.'); + } finally { + this.isSendingEmail = false; + } + }, + processChartData(details) { + if (!details || !details.length) return {labels: [], datasets: []}; + const daily = details.reduce((a, s) => { + const d = s.startTime.split(' ')[0]; + if (!a[d]) a[d] = {downloadBytes: 0, uploadBytes: 0}; + a[d].downloadBytes += Number(s.downloadBytes) || 0; + a[d].uploadBytes += Number(s.uploadBytes) || 0; + return a; + }, {}); + const dates = Object.keys(daily).sort((a, b) => new Date(a) - new Date(b)); + return { + labels: dates, + datasets: [{ + label: 'Download', + data: dates.map(d => daily[d].downloadBytes), + borderColor: 'rgba(15, 157, 88, 0.8)', + backgroundColor: 'rgba(15, 157, 88, 0.1)', + fill: true, + tension: 0.3, + pointRadius: 2, + borderWidth: 1.5 + }, { + label: 'Upload', + data: dates.map(d => daily[d].uploadBytes), + borderColor: 'rgba(0, 83, 132, 0.8)', + backgroundColor: 'rgba(0, 83, 132, 0.1)', + fill: true, + tension: 0.3, + pointRadius: 2, + borderWidth: 1.5 + }] + }; + }, + renderTransferChart() { + if (this.transferChartInstance) this.transferChartInstance.destroy(); + if (!this.$refs.transferChartCanvas || !this.transferMonthlyData?.details?.length || !window.Chart) return; + const d = this.processChartData(this.transferMonthlyData.details); + if (!d.labels.length) return; + const chartBackgroundColorPlugin = { + id: 'customCanvasBackgroundColor', + beforeDraw: (chart) => { + const {ctx} = chart; + ctx.save(); + ctx.globalCompositeOperation = 'destination-over'; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, chart.width, chart.height); + ctx.restore(); + } + }; + this.transferChartInstance = new Chart(this.$refs.transferChartCanvas.getContext('2d'), { + type: 'line', + data: d, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + type: 'time', + time: {unit: 'day', tooltipFormat: 'DD.MM.YYYY', displayFormats: {day: 'DD.MM'}}, + grid: {display: false}, + ticks: {maxRotation: 0, autoSkip: true, maxTicksLimit: 15} + }, + y: { + beginAtZero: true, + ticks: {callback: (v) => window.TT_CORE.formatBytes(v, 0)}, + grid: {color: 'rgba(0,0,0,0.05)'} + } + }, + plugins: { + tooltip: {callbacks: {label: (c) => `${c.dataset.label || ''}: ${window.TT_CORE.formatBytes(c.parsed.y)}`}}, + legend: {position: 'bottom', labels: {usePointStyle: true, boxWidth: 8, padding: 20}} + }, + interaction: {mode: 'index', intersect: false} + }, + plugins: [chartBackgroundColorPlugin] + }); + } + } +}; + +if (window.VueApp) { + window.VueApp.component('radius-transfer-modal', RadiusTransferModal); +} \ No newline at end of file diff --git a/public/js/pages/Radius/RadiusUnused.js b/public/js/pages/Radius/RadiusUnused.js index 463cb6d3f..a52bf45c9 100644 --- a/public/js/pages/Radius/RadiusUnused.js +++ b/public/js/pages/Radius/RadiusUnused.js @@ -1,15 +1,35 @@ -/* ===== RadiusUnused.js ===== */ -Vue.component('radius-unused-users', { +/* ===== RadiusUnused.js (Vue 3 + TT-Core) ===== */ + +const RadiusUnusedUsers = { + name: 'RadiusUnusedUsers', template: ` -
+
- +
- +
- + - - -