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) ? '

' : '';
- $logoXinonTag = file_exists($logoXinonPath) ? '

' : '';
- $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,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAMAAADDpiTIAAAAxlBMVEUAAAAAgP8AZv8Abf8Adv8Ad/8AgP8AeP8AgP8Aef8Aev8AgP8Aev8AfP8Aef8Aef8Aev8Ae/8Ae/8Aev8Ae/8Ae/8AfP8Ae/8AfP8Ae/8AfP8Ae/8AfP8Ae/8Aev8Ae/8AfP8AfP8Ae/8Ae/8Ae/8Ae/8Ae/8Ae/8AfP8Ae/8AfP8Ae/8Aev8Ae/8Ae/8Ae/8Ae/8Ae/8Ae/8Ae/8Ae/8Ae/8Ae/8Ae/8Ae/8Ae/8Ae/8Ae/8Ae/8Ae/8Ae/8Ae/8Ae/////9wRdatAAAAQHRSTlMABAUHDQ8QERIVFxgZISYoLE9VVldZYWhpamtub3B3eICMjY+Rt7i7xMXGx8jLzc/T19rt7vDx8vP09fn6/P3+tOfyVgAAAAFiS0dEQYnebE4AAAf4SURBVHja7dxtV1vHGYZRBbcJaqgd6pDYGFUGHFOnxAnEEEGCPP//V+Wr1zKG837mmdn7e9aS5r7OiBbkxQIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4D7Lg+Ozi6uUri7Ojg+WzqMuu4fn2/SJ7fnhrlOpxt7JTfrMzcmek6nCztF1utft6ydOp3xP36cv+u0751O6/U16wGbfCZXtxV160N0LZ1SyHz6mR3z80SkVfP9v06O2PgWK9ew6NXD9zEmV6atfUiO/7jirIq1TQ2tnVaLlH00D2HzrtAr0JjX2xmmV5+tN8wBuv3FexXmVWnjlvIrzc5sA3jmv0uxt2wSw9avh0nyfWnnuxApz2i6AUydWmLftAvjJiRXmsl0Al06sMB/aBfC7EyvMX+0C+NOJFSa15MQEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACEAAAhCAAAQgAAEIQAACEIAABCAAAQhAAAIQgAAEIAABCEAAAhCAAAQgAAE4MQEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAEgAASAABAAAkAACAABBLE8OD67uErp6uLs+GApgMzOZ2S7h+fbT493e364K4CczmdUeyc3n5/wzcmeAPI5nxHtHF3ff8a3r58IIJfzGc/T918+5d++E0Am5zOa/c1Dx7zZrz2AXM5nLC/uHj7nu5d1B/DysfN5EXv/Hz4+etLrmgNYPfo6Pv4Y+v7fNjjqVb0BrBq8kG3gT4Fn143OelVrAKtGr+T6WdT9v/ql4WGv6wxg1fCl/LoTNIB149Ne1RjAqvFrWcfcf/lHyugtZhdA8/3T5tuQAbxpc96r2gJYtXkxbyLu//Wm1YGv6wqg1f7p9puAAbxqeeKrmgJYtXw1rwIG8HPbI1/XE0Db/dO7ePvvbdu+yXHvgJwCaL1/2sb71fD3qb11HQGsOhzN83ABnHZ4l2PeAfkE0GX/dBougLdd3uaId0A2AXTaP/0ULoDLTu9zvDsglwC67Z8uwwXwodsbHe0OyCSAjvun38MF8FfKq4A8Aui6f/qzngBGKiCLADrvHzCADymvAnIIoPv+AT8CLru/2VF+EswggB77B/wh8G2PdzvGHTB/AH32D/g/A0/7vN0R7oDZA+i1f8D/I+h5r/c7/B0wdwD99k//CRdAh18GjXoHzBxAz/0D/jJo8S5ldQfMG0DP/dP/4u3f+g9CRr4DZg2g7/4h/yDkH9cppztgzgB677/5Z8AA2v1R6Oh3wIwB9N4/5h+FLpa9r4Ah74D5Aui//+ZfIQNo8cWQCe6A2QLov3/UL4Y0/2rYFO99rgAG2D/sV8Oafjl0kjtgpgAG2D/ul0Mbfj18mgLmCWCA/beh/5GQBv9AxESfArMEMMD+sf+BiMXiKGVyB8wRwAD7p6NFcKtM7oAZAhhi//8uFgoYpIDpA7B/VgVMHoD98ypg6gDsn1kBEwdg/9wKmDYA+2dXwKQB2D+/AqYMwP4ZFjBhAPbPsYDpArB/lgVMFoD98yxgqgDsn2kBEwVg/1wLmCYA+2dbwCQB2D/fAqYIwP4ZFzBBAPbPuYDxA7B/1gWMHoD98y5g7ADsn3kBIwdg/9wLGDcA+2dfwKgB2D//AsYMwP4BChgxAPtHKGC8AOwfooDRArB/jALGCsD+QQoYKQD7RylgnADsH6aAUQKwf5wCxgjA/oEKGCEA+0cqYPgA7B+qgMEDsH+sAoYOwP7BChg4APtHK2DYAOwfroBBA7B/vAKGDMD+AQsYMAD7RyxguADsH7KAwQKwf8wChgrA/kELGCgA+0ctYJgA7B+2gEECsH/cAoYIwP6BCxggAPtHLqB/APYPXUDvAOwfu4C+Adg/eAE9A7B/9AL6BWD/8AX0CsD+8QvoE4D9CyigRwD2L6GA7gHYv4gCOgdg/zIK6BqA/QspoGMA9i+lgG4B2L+YAjoFYP9yCugSgP0LKqBDAPYvqYD2Adi/qAJaB2D/sgpo+x/YP6cCUkgry+V0B0zO8193AfavuwD7112A/esuwP51F2D/uguwf90F2L/uAuxfdwH2r7sA+9ddgP3rLsD+dRdg/7oLsH/dBdi/7gLsX3cB9q+7APvXXYD96y7A/nUXYP+6C7D/XAX4+393gOdfAfZXgP0VYH8F2F8B9leA/RVgfwXYXwH2V4D9FWB/BdhfAfZXgP3LL8Dv/90Bnn93gOffHeD5dwd4/t0Bnn93gOffHeD5dwd4/t0Bnn93gOffHeD5dwd4/hVgf58C7n93gOdfAfZXgP39HODz3x3g+VeA/RVgfz8H+PxXgP0VYH8F2L/2AuxfdwH2r7sA+9ddgP3rLsD+dRdg/7oLsH/dBdi/7gLsX7SXdw/Pf/fSGZVtf/PQ/pt9J1S6f///y/u/f+p8yrdzdH3//LevnzidKuyd3Hw+/83JnpOpxu7h+fbT9bfnh7tOpS7Lg+Ozi6uUri7Ojg+WzgMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAuNffySAYnHI5DEAAAAAASUVORK5CYII=',
- '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.
-
-
-
-
-
-  |
- | Monat gesamt |
- | {$monthlyTotal} |
-
- |
-
-
-  |
- | Download |
- | {$monthlyDownload} |
-
- |
-
- | |
-
-
-
-  |
- | Upload |
- | {$monthlyUpload} |
-
- |
-
-
-  |
- | Dauer |
- | {$monthlyDuration} |
-
- |
-
-
-
-
-
- Tagesübersicht
-
-
-
- | Datum |
- Dauer |
- Upload |
- Download |
- Gesamt |
-
-
-
- {$dailyDetailsTable}
-
-
- 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: `
-
-
-
-
-
-
-
- {{ data.ip || '—' }}
-
-
-
- `,
- 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 (Suche)
ESTMK (Eingabe)Keine Treffer
- {{ disp }}
- Mehr Ergebnisse verfügbar
`,
- 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: `
-
+
`,
- 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 }}
-
+
-
- | Username | Info |
- |
+
+
+
+
+ | Username |
+ Info |
+
+
+
+
+ |
+
- {{ item.Username }} |
+
+ {{ item.Username }}
+ |
{{ item.Info }} |
-
-
{{ filteredNat.length }} Treffer gefunden
+
+
+ {{ filteredNat.length }} Treffer gefunden
+
-
+
Freie STF Benutzer {{ filteredStf.length }}
-
+
-
- | Username | Info |
- |
+
+
+
+
+ | Username |
+ Info |
+
+
+
+
+ |
+
- {{ item.Username }} |
+
+ {{ item.Username }}
+ |
{{ item.Info }} |
-
-
{{ 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
+
+
+
+
+
-
-
- | {{ h }} | Username | Kundennummer | Kundenname | Info |
- {{ item[h] }} | {{ item.fetched_username }} | {{ item.fetched_customerNumber }} | {{ item.fetched_customerName }} | {{ item.fetched_info }} |
-
-
{{ processedData.length }} Zeilen verarbeitet
+
+
+
+
+
+ | {{ h }} |
+ Username |
+ Kundennummer |
+ Kundenname |
+ Info |
+
+
+
+
+ {{ item[h] }} |
+ {{ item.fetched_username }} |
+ {{ item.fetched_customerNumber }} |
+ {{ item.fetched_customerName }} |
+ {{ item.fetched_info }} |
+
+
+
+ {{ 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
+
+
+
+
+
+
-
- Aktueller Kunde: {{ currentCustomerNumber || '—' }}
-
-
- | {{ h.label }} | ONT SN |
- {{ item[selectedColumns.kundennummer] }} | {{ item[selectedColumns.anschlussstrasse] }} | {{ item[selectedColumns.anschlussplz] }} | {{ item[selectedColumns.anschlusscity] }} | {{ item.ont_sn }} |
-
-
{{ processedData.length }} Zeilen verarbeitet
+
+
+
+
+
+ | {{ h.label }} |
+ ONT SN |
+
+
+
+
+ {{ item[selectedColumns.kundennummer] }} |
+ {{ item[selectedColumns.anschlussstrasse] }} |
+ {{ item[selectedColumns.anschlussplz] }} |
+ {{ item[selectedColumns.anschlusscity] }} |
+ {{ item.ont_sn }} |
+
+
+
+ {{ 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; i
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)); }
+ 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 }}
+ —
+
+
+
+
+
+
+
+
+
+ Kundennummer{{ radacctData.customerNumber || '—' }}
+ Kundenname{{ radacctData.customerName || '—' }}
+ Info{{ radacctData.info || '—' }}
+ WLAN Password{{ radacctData.wlanPassword || '—' }}
+ Bandbreite{{ radacctData.actualBandwidth || '—' }}
+
+
+
+
+
+
+ `,
+ 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: `
+
+
+
+
+
+
+
Kein Router mit dieser IP gefunden
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Gesendet{{ pingResult.packetsTransmitted }}
+
Empfangen{{ pingResult.packetsReceived }}
+
Verlust{{ pingResult.packetLoss }}%
+
Min / Avg / Max{{ pingResult.min }} / {{ pingResult.avg }} / {{ pingResult.max }} ms
+
+
+ Kein Ergebnis.
+
+
+
+
+
+
+
+
+
+
+ | # |
+ Bandbreite |
+ Übertragen |
+ Pakete |
+
+
+
+
+ | {{ idx + 1 }} |
+ {{ row.bpsFormatted }} |
+ {{ row.bytesFormatted }} |
+ {{ row.packets }} |
+
+
+
+
+
+
+ Aktualisiere...
+
+
+ Abgeschlossen
+
+
+
+
+
+
+
+
+
+ Konfiguration erfolgreich abgeschlossen.
+
+
+
+
+
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: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Gesamt {{ transferYear }}:
+
+
+
+ {{ window.TT_CORE.formatBytes(transferYearlyData.yearlySummary.grandTotalBytes) }}
+
+
+
+
+
+
+
+
+
+
+
Monat gesamt
+
+
+ {{ window.TT_CORE.formatBytes(transferMonthlyData?.summary?.grandTotalBytes || 0) }}
+
+
+
+
+
+
+
Download
+
+
+ {{ window.TT_CORE.formatBytes(transferMonthlyData?.summary?.totalDownloadBytes || 0) }}
+
+
+
+
+
+
+
Upload
+
+
+ {{ window.TT_CORE.formatBytes(transferMonthlyData?.summary?.totalUploadBytes || 0) }}
+
+
+
+
+
+
+
Dauer
+
+
+ {{ window.TT_CORE.formatDuration(transferMonthlyData?.summary?.totalDurationSeconds || 0) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Keine Daten in diesem Monat verfügbar
+
+
+
+
+
+
+
+
+
+ Keine detaillierten Daten für diesen Monat.
+
+
+
+
+ | Startzeit |
+ Dauer |
+ IP-Adresse |
+ Download |
+ Upload |
+ Gesamt |
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+ | {{ d.startTime }} |
+ {{ window.TT_CORE.formatDuration(d.durationSeconds) }} |
+ {{ d.ipAddress }} |
+ {{ window.TT_CORE.formatBytes(d.downloadBytes) }} |
+ {{ window.TT_CORE.formatBytes(d.uploadBytes) }} |
+ {{ window.TT_CORE.formatBytes(d.totalBytes) }} |
+
+
+
+
+
+
+
+
+
Daten konnten nicht geladen werden.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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: `
-
+
-
+
-
+
-
+
@@ -17,31 +37,129 @@ Vue.component('radius-unused-users', {
Dies kann einen Moment dauern, da große Datenmengen analysiert werden.
- | Kundennummer | Username | Letzter Login | Info | Sessions | Dauer | Traffic |
- | | | | | | |
-
- {{ item.customerNumber }} |
- {{ item.username }} |
- {{ item.lastLogin }} | {{ item.info }} |
- {{ item.totalSessions }} |
- {{ window.RadiusUtils.formatDuration(item.totalDurationSeconds) }} |
- {{ window.RadiusUtils.formatBytes(item.totalTrafficBytes) }} |
+
+
+
+ | Kundennummer |
+ Username |
+ Letzter Login |
+ Info |
+ Sessions |
+ Dauer |
+ Traffic |
+
+
-
-
-
{{ filteredUsers.length }} Treffer gefunden
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+ {{ item.customerNumber }}
+ |
+
+ {{ item.username }}
+ |
+ {{ item.lastLogin }} |
+ {{ item.info }} |
+ {{ item.totalSessions }} |
+ {{ window.TT_CORE.formatDuration(item.totalDurationSeconds) }} |
+ {{ window.TT_CORE.formatBytes(item.totalTrafficBytes) }} |
+
+
+
+
+
+
+ {{ filteredUsers.length }} Treffer gefunden
+
`,
- data: () => ({ users: [], isLoading: false, _initialized: false, hasSearched: false, window: window, visibleCount: 50, observer: null, activeFilter: 'all', filters: [{id: 'all', name:'Alle', icon:'fa-duotone fa-globe'},{id:'nat', name:'NAT*', icon:'fa-duotone fa-users'},{id:'st', name:'ST*', icon:'fa-duotone fa-server'},{id:'stf', name:'STF*', icon:'fa-duotone fa-id-card-clip'}] }),
- computed: { filteredUsers() { return this.activeFilter === 'all' ? this.users : this.users.filter(u => u.username && u.username.startsWith(this.activeFilter)); }, visibleFilteredUsers() { return this.filteredUsers.slice(0, this.visibleCount); } },
- mounted() { this.observer = new IntersectionObserver(([e]) => { if (e && e.isIntersecting) this.loadMore(); }, { root: this.$refs.tableWrap, threshold: 0.1 }); if (this.$refs.sentinel) this.observer.observe(this.$refs.sentinel); },
- beforeDestroy() { if (this.observer) this.observer.disconnect(); },
- updated() { if (this.observer && this.$refs.sentinel) { this.observer.disconnect(); this.observer.observe(this.$refs.sentinel); } },
+ data: () => ({
+ users: [],
+ isLoading: false,
+ _initialized: false,
+ hasSearched: false,
+ window: window,
+ visibleCount: 50,
+ observer: null,
+ activeFilter: 'all',
+ filters: [
+ {id: 'all', name:'Alle', icon:'fa-duotone fa-globe'},
+ {id:'nat', name:'NAT*', icon:'fa-duotone fa-users'},
+ {id:'st', name:'ST*', icon:'fa-duotone fa-server'},
+ {id:'stf', name:'STF*', icon:'fa-duotone fa-id-card-clip'}
+ ]
+ }),
+ computed: {
+ filteredUsers() {
+ return this.activeFilter === 'all'
+ ? this.users
+ : this.users.filter(u => u.username && u.username.startsWith(this.activeFilter));
+ },
+ visibleFilteredUsers() {
+ return this.filteredUsers.slice(0, this.visibleCount);
+ }
+ },
+ mounted() {
+ this.observer = new IntersectionObserver(([e]) => {
+ if (e && e.isIntersecting) this.loadMore();
+ }, { root: this.$refs.tableWrap, threshold: 0.1 });
+ if (this.$refs.sentinel) this.observer.observe(this.$refs.sentinel);
+ },
+ beforeUnmount() {
+ if (this.observer) this.observer.disconnect();
+ },
+ updated() {
+ if (this.observer && this.$refs.sentinel) {
+ this.observer.disconnect();
+ this.observer.observe(this.$refs.sentinel);
+ }
+ },
methods: {
- initIfNeeded() { if (this._initialized) return; this._initialized = true; },
- setFilter(filter) { this.activeFilter = filter; this.visibleCount = 50; },
- async fetchUnusedUsers() { this.isLoading = true; this.hasSearched = true; this.visibleCount = 50; this.users = []; try { const res = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=reportUnused`); this.users = res.ok ? (await res.json() || []) : []; } catch (e) { console.error("Failed to fetch unused users:", e); this.users = []; } this.isLoading = false; },
- loadMore() { if (this.visibleCount < this.filteredUsers.length) this.visibleCount += 50; }
+ initIfNeeded() {
+ if (this._initialized) return;
+ this._initialized = true;
+ },
+ setFilter(filter) {
+ this.activeFilter = filter;
+ this.visibleCount = 50;
+ },
+ async fetchUnusedUsers() {
+ this.isLoading = true;
+ this.hasSearched = true;
+ this.visibleCount = 50;
+ this.users = [];
+ try {
+ const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, {
+ params: { action2: 'reportUnused' }
+ });
+ this.users = data || [];
+ } catch (error) {
+ console.error("Failed to fetch unused users:", error);
+ this.users = [];
+ }
+ this.isLoading = false;
+ },
+ loadMore() {
+ if (this.visibleCount < this.filteredUsers.length) this.visibleCount += 50;
+ }
}
-});
\ No newline at end of file
+};
+
+// Register component with Vue 3 app
+if (window.VueApp) {
+ window.VueApp.component('radius-unused-users', RadiusUnusedUsers);
+}
diff --git a/public/js/pages/Radius/RadiusUsers.js b/public/js/pages/Radius/RadiusUsers.js
index ef8f55f7b..9c91cce9b 100644
--- a/public/js/pages/Radius/RadiusUsers.js
+++ b/public/js/pages/Radius/RadiusUsers.js
@@ -1,55 +1,104 @@
-Vue.component('radius-users', {
+/* ===== RadiusUsers.js (Vue 3 + TT-Core) ===== */
+
+const RadiusUsers = {
+ name: 'RadiusUsers',
template: `
-