diff --git a/Layout/default/VueViews/Vue.php b/Layout/default/VueViews/Vue.php
index c6bab7c16..52a0f5a7d 100644
--- a/Layout/default/VueViews/Vue.php
+++ b/Layout/default/VueViews/Vue.php
@@ -40,7 +40,7 @@ include($vueHeaderPath); ?>
diff --git a/application/Radius/RadiusController.php b/application/Radius/RadiusController.php
index 7e129785d..2685b1130 100644
--- a/application/Radius/RadiusController.php
+++ b/application/Radius/RadiusController.php
@@ -15,10 +15,9 @@ 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"]);
- Helper::renderVue($this, $this->mod, "Radius", ['CAN_BILLING' => $this->me->can("Billing")]);
+ Helper::renderVue($this, $this->mod, "Radius", ['CAN_BILLING' => $this->me->can("Billing"), 'HIDE_PAGE_TITLE' => true]);
}
-
protected function proxyUnsecureHTTPRequestToRadiusAction() {
$url = "http://radius.xinon.at/api.php?" . http_build_query($_GET);
$url = str_replace("proxyUnsecureHTTPRequestToRadius", "", $url);
diff --git a/public/js/pages/Radius/Radius.css b/public/js/pages/Radius/Radius.css
index ac8f00c41..285cbd69e 100644
--- a/public/js/pages/Radius/Radius.css
+++ b/public/js/pages/Radius/Radius.css
@@ -1,81 +1,434 @@
-.radius-view-selector {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(200px, max-content));
- grid-gap: 10px;
- justify-content: start;
- margin-bottom: 20px;
-}
-
-.radius-view-selector > *,
-.radius-view-selector > * > * {
- width: 100%;
-}
-
-@media (max-width: 576px) {
- .radius-view-selector {
- grid-template-columns: 1fr;
- }
-}
-
-.filters {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
- grid-gap: 15px;
- margin-bottom: 20px;
- justify-content: center;
- align-items: center;
-}
-
-.status-dot {
- display: inline-block;
- width: 10px;
- height: 10px;
- border-radius: 50%;
-}
-
-.status-dot.online {
- background-color: green;
-}
-
-.status-dot.offline {
- background-color: grey;
-}
-
-.free-users-container {
- display: grid;
- grid-template-columns: 1fr 1fr;
- grid-gap: 20px;
-}
-
-@media (max-width: 768px) {
- .free-users-container {
- grid-template-columns: 1fr;
- }
-}
-
-.free-users-column {
- background-color: #f8f9fa;
- padding: 15px;
- border-radius: 5px;
-}
-
-
-
-/*
-RADIUS ONT PARSER
+/* ===== Radius.css =====
+ Light theme, brand colors (blue), green online accent, compact density.
+ Scoped with .radius-scope to avoid page-wide resets.
*/
-.loading-overlay {
+
+:root{
+ --brand-blue: #005384;
+ --bg: #ffffff;
+ --card: #ffffff;
+ --card-2: #f8fafc;
+ --muted: #667085;
+ --text: #0b1320;
+ --accent: var(--brand-blue);
+ --accent-2: #1e88c9;
+ --ok: #0f9d58; /* ✅ green accent */
+ --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);
+}
+
+/* container & basic colors */
+.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; }
+
+/* simple grid utilities (used by Free Users + ONT views) */
+.radius-scope .grid { display:grid; }
+.radius-scope .g-4 { gap: 16px; }
+.radius-scope .g-6 { gap: 24px; }
+.radius-scope .cols-2 { grid-template-columns: repeat(2, minmax(0,1fr)); }
+.radius-scope .cols-1 { grid-template-columns: 1fr; }
+@media (min-width: 900px){ .radius-scope .cols-2@lg { grid-template-columns: repeat(2, 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; } }
+
+
+/* badges / headings / clusters */
+.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; }
+.radius-scope .h5 { font-size:16px; font-weight:800; letter-spacing:.2px; }
+.radius-scope .cluster { display:flex; gap:10px; flex-wrap:wrap; align-items: center; }
+
+/* container */
+.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; }
+
+/* main header inside card */
+.radius-scope .pane-header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom: 10px; flex-wrap: wrap;}
+.radius-scope .pane-header .title { display:flex; align-items:center; gap:10px; font-weight:800; letter-spacing:.2px; font-size: 18px; }
+/* ✅ green dot (single branding only in the main header) */
+.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; margin-bottom: 6px; }
+.radius-scope .view-select-wrap { display: none; }
+
+@media (max-width: 800px) {
+ .radius-scope .view-tabs { display: none; }
+ .radius-scope .view-select-wrap { display: block; }
+}
+
+
+/* buttons */
+.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;
+}
+.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 .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;
+}
+.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; }
+
+/* inputs */
+.radius-scope .input-wrap { position: relative; }
+.radius-scope .ri {
+ box-sizing: border-box; /* Add this line */
+ 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 .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 .btn-clear {
+ position: absolute; right: 8px; top: 15%; transform: translateY(-50%);
+ width: 28px; height: 28px; border-radius: 8px; border: none;
+ background: #f3f7fa; color:#5a7891; /* Changed color from #7a8fa1 */ cursor: pointer;
+ transition: all .2s ease; opacity: 1; transform: scale(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 .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; }
+
+
+/* iOS-like switch */
+.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; }
+
+/* Autocomplete */
+.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; /* Change this from -6px to 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-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); }
+
+/* Filters grid (labels optional, works fine without) */
+.radius-scope .filters { display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:12px; align-items:flex-end; }
+.radius-scope .field label { display:block; margin: 0 0 6px; color: var(--muted); font-size: 12px; }
+
+/* tables */
+.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; /* ADDED THIS LINE */
+ border-collapse: collapse;
+ background: #fff;
+ table-layout: fixed;
+}
+.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; z-index:1; font-size:12px; color:#344054; text-transform:uppercase; letter-spacing:.04em; }
+.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; }
+
+/* table placeholder for no-input or no-results */
+.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: #bdc8d8; }
+
+
+/* row entrance */
+.radius-scope .row-fade-in { animation: rowIn .22s ease; }
+@keyframes rowIn { from { opacity:0; transform: translateY(2px);} to {opacity:1; transform: none;} }
+
+/* skeletons */
+.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} }
+
+/* button inline loader */
+.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);} }
+
+/* modal */
+.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: 1000; }
+.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; }
+.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;} }
+
+/* key-value in modal (OLD) */
+.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); }
+
+/* Redesigned Modal Key-Value */
+.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;
+}
+.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; }
+
+/* online state chip */
+.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%; }
+.radius-scope .ros-chip.is-clickable { cursor: pointer; transition: background-color .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; }
+
+/* ONT views shared styles */
+.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:block; border:1px dashed #cfe4f3; border-radius: var(--radius); padding: 30px; text-align:center; background:#f8fbff; cursor:pointer; }
+.radius-scope .file-cta { display:flex; flex-direction:column; gap:8px; align-items:center; justify-content:center; color:#365972; }
+
+/* ONT Parser Loading Overlay */
+.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 .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: #f7c423; }
+
+.radius-scope .alert.error { padding:10px 12px; border-radius:10px; border:1px solid #ffd6d6; background:#fff3f3; color:#8a1d1d; }
+
+/* section entrance */
+.radius-scope .card-in { animation: cardIn .18s ease; }
+@keyframes cardIn { from{ opacity:0; transform: translateY(4px);} to { opacity:1; transform: none;} }
+
+/* ===== NEW STYLES ===== */
+
+/* Custom Animated Tooltip */
+[data-tooltip] {
position: relative;
- padding: 20px;
- background: white;
- border-radius: 4px;
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+[data-tooltip]::before, [data-tooltip]::after {
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+ opacity: 0;
+ pointer-events: none;
+ transition: all .18s ease-in-out;
+ z-index: 10;
+}
+[data-tooltip]::before {
+ content: '';
+ bottom: calc(100% + 0px);
+ 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);
}
-.progress {
- height: 30px;
+/* IP Input Focus Tooltip */
+.radius-scope .ip-field-wrapper { position: relative; }
+.radius-scope .ip-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;
}
-
-.progress-bar {
- transition: width 0.3s ease;
+.radius-scope .ip-field-wrapper:focus-within .ip-focus-tooltip {
+ opacity: 1;
+ transform: translateY(0);
}
\ No newline at end of file
diff --git a/public/js/pages/Radius/Radius.js b/public/js/pages/Radius/Radius.js
index 3dc6126a1..ee5f6bb15 100644
--- a/public/js/pages/Radius/Radius.js
+++ b/public/js/pages/Radius/Radius.js
@@ -1,823 +1,242 @@
-// Function to calculate similarity percentage between two strings
+/* ===== Radius.js =====
+ * Main entry point for the Radius module (light theme).
+ * Navigation jetzt innerhalb der Hauptkarte; Free-Tab lädt erst bei erstem Klick.
+ */
+
+/* ---------- Shared utils (global) ---------- */
function calculateSimilarity(str1, str2) {
- // Normalize strings by converting them to lowercase
- str1 = str1.toLowerCase();
- str2 = str2.toLowerCase();
-
- let matchCount = 0;
-
- // Check how many characters in str1 exist in str2
- for (let char of str1) {
- if (str2.includes(char)) {
- matchCount++;
- }
- }
-
- // Calculate similarity percentage
- return (matchCount / str1.length) * 100;
+ 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; // Similarity threshold in percentage
-
- // Validate each field against the info string
- return !(calculateSimilarity(strasse, info) < thresholds ||
+ const thresholds = 90;
+ return !(
+ calculateSimilarity(strasse, info) < thresholds ||
calculateSimilarity(plz, info) < thresholds ||
- calculateSimilarity(stadt, info) < thresholds);
+ calculateSimilarity(stadt, info) < thresholds
+ );
}
-
-
-Vue.component('radius-ont-parser', {
- template: `
-
-
-
-
Schritt 1: Excel (XLSX) Upload
-
-
-
-
-
-
-
Schritt 2: Spaltenzuordnung
-
-
-
- {{ field.label }}
-
- {{ header }}
-
-
-
-
-
Start Processing
-
-
-
-
-
-
-
Schritt 3: Ergebnisse
-
-
-
-
-
-
- {{ header.label }}
-
- ONT SN
-
-
-
-
- {{ row[selectedColumns.kundennummer] }}
- {{ row[selectedColumns.anschlussstrasse] }}
- {{ row[selectedColumns.anschlussplz] }}
- {{ row[selectedColumns.anschlusscity] }}
- {{ row.ont_sn }}
-
-
-
-
-
-
-
-
-
-
-
- {{ Math.round(progress) }}%
-
-
-
Processing {{ currentRow + 1 }} of {{ totalRows }}
-
-
- `,
-
- data() {
- return {
- 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
- };
- },
-
- methods: {
- async handleFileUpload(event) {
- const file = event.target.files[0];
- if (!file) return;
-
- // Load XLSX library dynamically
- await this.loadXLSX();
-
- const reader = new FileReader();
- reader.onload = (e) => {
- const data = new Uint8Array(e.target.result);
- const workbook = XLSX.read(data, { type: "array" });
- const worksheet = workbook.Sheets[workbook.SheetNames[0]];
-
- // Read entire sheet as rows of arrays (no header detection yet)
- const allRows = XLSX.utils.sheet_to_json(worksheet, {
- header: 1, // Return rows as arrays
- blankrows: false // Skip blank rows
- });
-
- // If there's only one row or something unexpected, do a basic parse
- if (allRows.length < 2) {
- const fallbackData = XLSX.utils.sheet_to_json(worksheet);
- this.parsedData = fallbackData;
- this.headers = Object.keys(fallbackData[0] || {});
- this.step = 2;
- return;
- }
-
- const firstRow = allRows[0] || [];
- const secondRow = allRows[1] || [];
-
- // Count how many cells in each row are empty
- const firstRowEmptyCount = firstRow.length - firstRow.filter(Boolean).length;
- const secondRowEmptyCount = secondRow.length - secondRow.filter(Boolean).length;
-
- // If the difference in empty cells is more than 25% of the number of columns in the second row,
- // assume the first row is mostly empty and use the second row as the header
- const useSecondRowAsHeader = (firstRowEmptyCount - secondRowEmptyCount) > 0.25 * secondRow.length;
-
- // Now parse again with the correct header row
- if (useSecondRowAsHeader) {
- this.parsedData = XLSX.utils.sheet_to_json(worksheet, {
- range: 1, // Start reading data after the first row
- header: secondRow, // Use the second row as the header
- defval: "" // Optional: fill empty cells with empty string
- }).slice(1); // Skip the first row
- this.headers = secondRow;
- } else {
- this.parsedData = XLSX.utils.sheet_to_json(worksheet, {
- range: 0, // Start at the first row
- header: firstRow, // Use the first row as the header
- defval: ""
- });
- this.headers = firstRow;
- }
-
- this.step = 2;
- };
- reader.readAsArrayBuffer(file);
- },
-
-
-
- async loadXLSX() {
- if (!window.XLSX) {
- await new Promise((resolve, reject) => {
- const script = document.createElement('script');
- script.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js';
- script.onload = resolve;
- script.onerror = reject;
- document.head.appendChild(script);
- });
- }
- },
-
- async startProcessing() {
- this.loading = true;
- this.totalRows = this.parsedData.length;
- const processedRows = [];
-
- mainLoop:
- for (let i = 0; i < this.parsedData.length; i++) {
- this.currentRow = i;
- this.progress = ((i + 1) / this.parsedData.length) * 100;
-
- // Simulate processing
- await this.sleep(100);
-
- // Process row here
- const row = this.parsedData[i];
-
- const findUserResponse = await fetch(window.TT_CONFIG['BASE_PATH'] + '/Radius/proxyUnsecureHTTPRequestToRadius?custnume=' + row[this.selectedColumns.kundennummer]);
- const findUserData = await findUserResponse.json();
-
- if (findUserData.length === 0) {
- row.ont_sn = 'N/A - Kein Benutzer mit dieser Kundennummer gefunden';
- processedRows.push(row);
- } else if (findUserData.length === 1) {
- const username = findUserData[0].username;
- const radacctResponse = await fetch(window.TT_CONFIG['BASE_PATH'] + '/Radius/proxyUnsecureHTTPRequestToRadius?skipAdditional=true&action2=fetchRadacct&username=' + username);
- const radacctData = await radacctResponse.json();
-
- row.ont_sn = radacctData.ont_sn || 'N/A - Keine ONT SN gefunden';
- processedRows.push(row);
-
- } else if (findUserData.length > 1) {
- // check string simulairty of strasse, plz, stadt and atleast of 90% of each should be inside findUserData[].info
- // if not, ont_sn = N/A - Anschluss konnte nicht zugeordnet werden
-
- const strasse = row[this.selectedColumns.anschlussstrasse];
- const plz = row[this.selectedColumns.anschlussplz];
- const stadt = row[this.selectedColumns.anschlusscity];
- const info = findUserData[0].info;
-
- for (let user of findUserData) {
- if (validateData(strasse, plz, stadt, info)) {
- const username = user.username;
- const radacctResponse = await fetch(window.TT_CONFIG['BASE_PATH'] + '/Radius/proxyUnsecureHTTPRequestToRadius?skipAdditional=true&action2=fetchRadacct&username=' + username);
- const radacctData = await radacctResponse.json();
-
- row.ont_sn = radacctData.ont_sn || 'N/A - Keine ONT SN gefunden';
- processedRows.push(row);
- continue mainLoop;
- }
- }
-
- row.ont_sn = 'N/A - Anschluss konnte nicht zugeordnet werden';
- processedRows.push(row);
- }
- }
-
- this.loading = false;
- this.processedData = processedRows;
- this.step = 3;
- },
- 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");
- },
- sleep(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
+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;
}
-});
+}
+window.RadiusUtils = { calculateSimilarity, validateData, copyToClipboard };
+/* ---------- Online state chip (fetches radacct when visible) ---------- */
Vue.component('radius-online-state', {
- props: ['username'],
+ props: { username: String },
+ data: () => ({ data: null, observed: false, ob: null }),
template: `
-
-
-
+
+
+
+
+
+
-
-
- {{ data.ip }}
-
+
+
+
+ {{ data.ip || '—' }}
+
`,
- data: () => ({
- data: null,
- observer: null,
- observed: false
- }),
mounted() {
- this.observer = new IntersectionObserver(entries => {
- if (entries[0].isIntersecting && !this.observed) {
- this.observed = true;
- this.fetchOnlineState();
+ this.ob = new IntersectionObserver((en) => {
+ if (en[0].isIntersecting && !this.observed) {
+ this.observed = true; this.fetchState();
}
}, { threshold: 0.1 });
-
- this.observer.observe(this.$refs.container);
- },
- beforeDestroy() {
- this.observer?.disconnect();
+ if (this.$refs.root) {
+ this.ob.observe(this.$refs.root);
+ }
},
+ beforeDestroy() { this.ob?.disconnect(); },
methods: {
- async fetchOnlineState() {
- const response = await fetch(`${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${this.username}`);
- if (response.ok) this.data = await response.json();
+ async fetchState() {
+ try {
+ const url = `${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(this.username)}`;
+ const r = await fetch(url);
+ if (r.ok) this.data = await r.json();
+ else this.data = { online: false, ip: null };
+ } catch { this.data = { online: false, ip: null }; }
+ },
+ async copyIp() {
+ if (!this.data?.ip) return;
+ await window.RadiusUtils.copyToClipboard(this.data.ip);
+ if (window.notify) {
+ window.notify('success', 'IP-Adresse kopiert!');
+ }
}
}
-})
-
-
-
-Vue.component('radius', {
- template: `
-
-
- Radius
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Freie NAT Benutzer ({{ freeNatUsers.length }})
-
-
-
-
Freie STF Benutzer ({{ freeStfUsers.length }})
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Status:
- {{ radacctData.online ? 'Online' : 'Offline' }}
-
-
- IP:
- {{ radacctData.ip }}
-
-
- Username:
- {{ radacctData.username }}
-
-
- Customer Number:
- {{ radacctData.customerNumber }}
-
-
- Customer Name:
- {{ radacctData.customerName }}
-
-
- Info:
- {{ radacctData.info }}
-
-
- WLAN Password:
- {{ radacctData.wlanPassword }}
-
-
- Bandbreite:
- {{ radacctData.actualBandwidth }}
-
-
-
-
-
- `,
- data() {
- return {
- view: 'radius',
- billAddrAutoCompleteUrl: window.TT_CONFIG['BASE_PATH'] + '/Address/Api?do=findAddress&fibu_primary_account=1',
- radiusUsers: [],
- freeNatUsers: [],
- freeStfUsers: [],
- username: '',
- info: '',
- ip: '',
- custnum: '',
- window: window,
- showRadacctModal: false,
- checkOnlineState: 0,
- radacctData: null,
- isLoading: false,
- searchCount: 0,
- }
- },
- async mounted() {
- console.log("hallo");
- await this.loadFreeUsers();
- },
- methods: {
- hideRadacctModal() {
- this.showRadacctModal = false;
- },
- async loadRadiusUsers() {
- this.isLoading = true;
- this.radiusUsers = [];
- let custnum = '';
- if (this.$refs.billAddr.displayValue.length > 5) {
- custnum = this.$refs.billAddr.displayValue.match(/\[(\d+)]/)[1];
- }
-
- const params = new URLSearchParams({
- username: this.username,
- info: this.info,
- custnum: custnum,
- ip: this.ip,
- });
- const response = await fetch(`${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?${params.toString()}`);
- if (response.ok) {
- const users = await response.json()
- if (users.length < 6) {
- this.checkOnlineState = 1;
- }
- this.radiusUsers = users;
- } else {
- console.error('Failed to load radius users');
- }
- this.isLoading = false;
- this.searchCount = this.searchCount + 1;
- },
- async fetchRadacctData(username) {
- const params = new URLSearchParams({
- action2: 'fetchRadacct',
- username: username,
- });
- const response = await fetch(`${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?${params.toString()}`);
- if (response.ok) {
- this.radacctData = await response.json();
- this.showRadacctModal = true;
- } else {
- console.error('Failed to fetch radacct data');
- }
- },
- async loadFreeUsers() {
- try {
- const natResponse = await fetch(window.TT_CONFIG['BASE_PATH'] + '/Radius/proxyUnsecureHTTPRequestToRadius?action2=free_user&filter=nat');
- const stfResponse = await fetch(window.TT_CONFIG['BASE_PATH'] + '/Radius/proxyUnsecureHTTPRequestToRadius?action2=free_user&filter=stf');
-
- if (natResponse.ok && stfResponse.ok) {
- const natData = await natResponse.json();
- const stfData = await stfResponse.json();
-
- this.freeNatUsers = natData.users;
- this.freeStfUsers = stfData.users;
- } else {
- console.error('Failed to load free users');
- }
- } catch (error) {
- console.error('Error loading free users:', error);
- }
-
- },
- },
});
-Vue.component('radius-ont-finder', {
+
+/* ---------- 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, selectedDisplay: '', selectedCustnum: '' };
+ },
+ watch: { value(v){ if(v!==this.q) this.q=v; } },
template: `
-
-
-
-
Schritt 1: Excel (XLSX) Upload
-
Bitte laden Sie eine XLSX-Datei hoch, die mindestens eine Spalte mit dem Header 'Serial' für die ONT-Seriennummern enthält. Optional kann eine 'MAC' Spalte für eine alternative Suche verwendet werden.
-
-
{{ uploadError }}
-
+
+
+
+
+
-
-
-
Schritt 2: Ergebnisse
-
- Ergebnisse herunterladen
-
-
- Neue Datei hochladen
-
-
-
-
-
- {{ header }}
- Username
- Kundennummer
- Kundenname
- Info
-
-
-
-
- {{ row[header] }}
- {{ row.fetched_username }}
- {{ row.fetched_customerNumber }}
- {{ row.fetched_customerName }}
- {{ row.fetched_info }}
-
-
-
+
+
-
-
-
-
-
- Loading...
-
-
-
- {{ Math.round(progress) }}%
-
-
-
Verarbeite Zeile {{ currentRow + 1 }} von {{ totalRows }}
-
Aktuelle Suche: {{ currentSerial }}
-
-
+
`,
-
- data() {
- return {
- 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
- };
- },
-
+ computed: { highlightedId(){ const keys=Object.keys(this.items); return keys[this.highlighted] || null; } },
+ created() { this.debouncedFetch = this.debounce(() => this.fetchItems(), 220); },
methods: {
- resetComponent() {
- Object.assign(this.$data, this.$options.data.call(this));
- const input = this.$el.querySelector('input[type="file"]');
- if (input) input.value = '';
+ maybeOpen(){ this.open = true; if (this.q) this.debouncedFetch(); },
+ deferClose(){ setTimeout(()=> this.open=false, 150); },
+ clear(){ this.q=''; this.items={}; this.highlighted=-1; this.emitSelection('', ''); },
+ move(dir){ const keys=Object.keys(this.items); if (!keys.length) return; this.highlighted=(this.highlighted+dir+keys.length)%keys.length; },
+ chooseHighlighted(emitEnter){ const id=this.highlightedId; if (id) this.choose(id, this.items[id], emitEnter); else if (emitEnter) this.$emit('enter'); },
+ choose(id, display, emitEnter){ this.q=display; this.selectedCustnum=(display.match(/\[(\d+)\]/)||[])[1] || ''; this.selectedDisplay=display; this.emitSelection(this.selectedCustnum, this.selectedDisplay); 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.q || this.q.length < 2) { this.items={}; return; }
+ this.busy = true;
+ try{
+ const base = window.TT_CONFIG['BASE_PATH'] || '';
+ const url = `${base}/Address/Api?do=findAddress&fibu_primary_account=1&q=${encodeURIComponent(this.q)}`;
+ const r = await fetch(url);
+ if (r.ok) {
+ const j = await r.json();
+ this.items = (j && j.result && j.result.addresses) ? j.result.addresses : {};
+ this.highlighted = 0;
+ } else { this.items = {}; }
+ } catch { this.items = {}; }
+ this.busy = false;
},
+ debounce(fn, ms){ let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a), ms); }; }
+ }
+});
- async handleFileUpload(event) {
- const file = event.target.files[0];
- this.uploadError = null;
- if (!file) return;
+/* ---------- Generic Modal ---------- */
+Vue.component('radius-modal', {
+ props: { show: Boolean, title: String },
+ template: `
+
+
+
+ `
+});
- this.loading = true;
+/* ---------- Root View:
---------- */
+Vue.component('radius', {
+ template: `
+
+
+
- try {
- await this.loadXLSX();
- const data = await new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = e => resolve(new Uint8Array(e.target.result));
- reader.onerror = () => reject(new Error("Fehler beim Lesen der Datei."));
- reader.readAsArrayBuffer(file);
- });
-
- const workbook = XLSX.read(data, { type: 'array' });
- const worksheet = workbook.Sheets[workbook.SheetNames[0]];
- this.parsedData = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
-
- if (!this.parsedData.length) {
- throw new Error("Die hochgeladene Datei ist leer oder konnte nicht gelesen werden.");
- }
-
- 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 (error) {
- console.error("File processing error:", error);
- this.uploadError = error.message;
- this.loading = false;
- this.step = 1;
+
+
+
+
+
+
+
+ `,
+ data() { return { view: 'users', window: window, _freeInitDone: false }; },
+ computed: {
+ viewOptions() {
+ const options = [
+ { id: 'users', name: 'Benutzer', icon: 'fa-duotone fa-users' },
+ { id: 'free', name: 'Freie Benutzer', icon: 'fa-duotone fa-user-plus' }
+ ];
+ if (window['TT_CONFIG']['CAN_BILLING'] === '1') {
+ options.push({ id: 'ont', name: 'ONT Parser', icon: 'fa-duotone fa-diagram-project' });
+ options.push({ id: 'ontReverse', name: 'ONT Reverse', icon: 'fa-duotone fa-arrows-rotate' });
}
- },
-
- async loadXLSX() {
- if (window.XLSX) return;
- return new Promise((resolve, reject) => {
- const script = document.createElement('script');
- script.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js';
- script.async = true;
- script.onload = resolve;
- script.onerror = () => reject(new Error("Could not load XLSX library."));
- document.head.appendChild(script);
- });
- },
-
- async startProcessing() {
- this.loading = true;
- this.totalRows = this.parsedData.length;
- this.processedData = [];
- this.progress = 0;
- this.currentRow = 0;
-
- const snApiUrlBase = `${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?ont_sn=`;
- const macApiUrlBase = `${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?username=`;
- const sesApiUrlBase = `${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?action2=find_by_current_session&mac=`;
-
- const setRowStatus = (row, msg, data = {}) => {
- const defaultData = { username: `N/A - ${msg}`, customerNumber: 'N/A', customerName: 'N/A', info: 'N/A' };
- Object.keys(this.fetchedKeys).forEach(key => row[this.fetchedKeys[key]] = data[key] || defaultData[key]);
- };
-
- for (const [i, row] of this.parsedData.entries()) {
- this.currentRow = i;
- const newRow = { ...row };
- const serialNumber = row[this.serialColumnName]?.trim();
- this.currentSerial = `SN: ${serialNumber || 'Leer'}`;
-
- if (!serialNumber) {
- setRowStatus(newRow, 'Leere Seriennummer');
- this.processedData.push(newRow);
- this.progress = ((i + 1) / this.totalRows) * 100;
- continue;
- }
-
- let found = false;
-
- try {
- const snResponse = await fetch(snApiUrlBase + encodeURIComponent(serialNumber));
- if (snResponse.ok) {
- const snData = await snResponse.json();
- if (snData?.length > 0) {
- setRowStatus(newRow, '', snData[0]);
- found = true;
- }
- }
- } catch (error) {
- console.error(`Fetch error for SN ${serialNumber}:`, error);
- }
-
- if (!found && this.originalHeaders.includes(this.macColumnName)) {
- const macAddress = row[this.macColumnName]?.trim();
- this.currentSerial = `MAC: ${macAddress || 'Leer'}`;
- if (macAddress && macAddress.length === 12) {
- const formattedMac = macAddress.toUpperCase().match(/.{1,2}/g).join(':');
- try {
- const sesResponse = await fetch(`${sesApiUrlBase}${encodeURIComponent(formattedMac)}`);
- if (sesResponse.ok) {
- const sesData = await sesResponse.json();
- if (sesData?.length === 0) continue;
-
- const username = sesData[0];
-
- const macResponse = await fetch(`${macApiUrlBase}${encodeURIComponent(username)}&info=&custnum=`);
- if (macResponse.ok) {
- const macData = await macResponse.json();
- if (macData?.length > 0) {
- setRowStatus(newRow, '', macData[0]);
- console.log("found via MAC:", formattedMac, macData[0]);
- found = true;
- }
- }
- }
-
-
-
- } catch (error) {
- console.error(`Fetch error for MAC ${formattedMac}:`, error);
- }
- }
- }
-
- if (!found) {
- setRowStatus(newRow, 'Keinen Benutzer gefunden');
- }
-
- this.processedData.push(newRow);
- this.progress = ((i + 1) / this.totalRows) * 100;
- if ((i + 1) % 20 === 0) await this.sleep(20);
- }
-
- this.loading = false;
- this.step = 2;
- this.currentSerial = '';
- },
-
- downloadResults() {
- if (!this.processedData.length) return;
- try {
- const dataToExport = this.processedData.map(row => {
- const exportRow = {};
- this.originalHeaders.forEach(header => { exportRow[header] = row[header]; });
- exportRow['Username'] = row[this.fetchedKeys.username];
- exportRow['Kundennummer'] = row[this.fetchedKeys.customerNumber];
- exportRow['Kundenname'] = row[this.fetchedKeys.customerName];
- exportRow['Info'] = row[this.fetchedKeys.info];
- return exportRow;
- });
-
- const ws = XLSX.utils.json_to_sheet(dataToExport);
- const wb = XLSX.utils.book_new();
- XLSX.utils.book_append_sheet(wb, ws, "ONT_Finder_Results");
- const timestamp = new Date().toISOString().replace(/[-:.]/g, "").slice(0, 14);
- XLSX.writeFile(wb, `ont_finder_results_${timestamp}.xlsx`);
- } catch (error) {
- console.error("Error generating results file:", error);
- alert("Fehler beim Erstellen der Excel-Datei für den Download.");
- }
- },
-
- sleep(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- },
+ return options;
+ }
},
-
- mounted() {
- if (!window.TT_CONFIG?.BASE_PATH) {
- console.warn(`Global TT_CONFIG.BASE_PATH not found. API calls will use fallback path: ${this.apiBasePath}`);
+ watch: {
+ view(newView) {
+ this.switchView(newView);
+ }
+ },
+ methods: {
+ switchView(v){
+ this.view = v;
+ if (v==='free' && !this._freeInitDone) {
+ this.$nextTick(()=>{ this.$refs.freeView?.initIfNeeded?.(); this._freeInitDone = true; });
+ }
}
}
-});
\ No newline at end of file
+});
diff --git a/public/js/pages/Radius/RadiusFreeUsers.js b/public/js/pages/Radius/RadiusFreeUsers.js
new file mode 100644
index 000000000..c6c5515e6
--- /dev/null
+++ b/public/js/pages/Radius/RadiusFreeUsers.js
@@ -0,0 +1,111 @@
+/* ===== RadiusFreeUsers.js =====
+ * Vereinfachte freie Benutzer:
+ * - Kein Suchfeld, nur Reload-Button
+ * - Autoload beim ersten Tab-Klick (initIfNeeded() wird von aufgerufen)
+ * - Zweispaltiges Layout bei breiten Screens
+ * - Robust gegen Server-Response (users/count/filter), trimmt Info
+ */
+
+Vue.component('radius-free-users', {
+ template: `
+
+
+
+
+
+ Freie NAT Benutzer {{ nat.length }}
+
+ Neu laden
+
+
+
+
+
+
+ Username Info
+
+
+
+
+ {{ u.Username }}
+ {{ u.Info }}
+
+
+ Keine Treffer
+
+
+
+
+
+
+
+
+ Freie STF Benutzer {{ stf.length }}
+
+ Neu laden
+
+
+
+
+
+
+ Username Info
+
+
+
+
+ {{ u.Username }}
+ {{ u.Info }}
+
+
+ Keine Treffer
+
+
+
+
+
+
+ `,
+ data() {
+ return {
+ nat: [],
+ stf: [],
+ loadingNat: false,
+ loadingStf: false,
+ _initialized: false
+ };
+ },
+ methods: {
+ initIfNeeded(){
+ if (this._initialized) return;
+ this._initialized = true;
+ this.reload();
+ },
+ 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 reload() {
+ // NAT
+ this.loadingNat = true;
+ try {
+ const natRes = await fetch(window.TT_CONFIG['BASE_PATH'] + '/Radius/proxyUnsecureHTTPRequestToRadius?action2=free_user&filter=nat');
+ const j = natRes.ok ? await natRes.json() : { users: [] };
+ this.nat = this.normalizeUsers(j.users);
+ } catch { this.nat = []; }
+ this.loadingNat = false;
+
+ // STF
+ this.loadingStf = true;
+ try {
+ const stfRes = await fetch(window.TT_CONFIG['BASE_PATH'] + '/Radius/proxyUnsecureHTTPRequestToRadius?action2=free_user&filter=stf');
+ const j = stfRes.ok ? await stfRes.json() : { users: [] };
+ this.stf = this.normalizeUsers(j.users);
+ } catch { this.stf = []; }
+ this.loadingStf = false;
+ }
+ }
+});
diff --git a/public/js/pages/Radius/RadiusOntFinder.js b/public/js/pages/Radius/RadiusOntFinder.js
new file mode 100644
index 000000000..173b5c9c4
--- /dev/null
+++ b/public/js/pages/Radius/RadiusOntFinder.js
@@ -0,0 +1,165 @@
+/* ===== RadiusOntFinder.js =====
+ * Reverse lookup by ONT Serial (and optional MAC). Styling via shared ONT CSS utilities.
+ */
+
+Vue.component('radius-ont-finder', {
+ template: `
+
+
+
+
Schritt 1 · Excel (XLSX) Upload
+
+ Datei muss die Spalte Serial enthalten. Optional MAC (12 Zeichen, ohne Doppelpunkte).
+
+
+
+
+
+
+
Hierhin ziehen oder Datei auswählen
+
+
+
{{ uploadError }}
+
+
+
+
+
Ergebnisse
+
+ Ergebnisse herunterladen
+ Neue Datei
+
+
+
+
+
+
+
+ {{ h }}
+ Username
+ Kundennummer
+ Kundenname
+ Info
+
+
+
+
+ {{ row[h] }}
+ {{ row.fetched_username }}
+ {{ row.fetched_customerNumber }}
+ {{ row.fetched_customerName }}
+ {{ row.fetched_info }}
+
+
+ Keine Daten
+
+
+
+
+
+
+
+
+
+
Verarbeitung läuft...
+
Aktuell: {{ currentSerial || '—' }}
+
+
Verarbeite Zeile {{ currentRow + 1 }} von {{ totalRows }}
+
+
+
+
+ `,
+ data() {
+ return {
+ 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=''; },
+ onDrop(e){ const f=e.dataTransfer.files?.[0]; if (f) this.readXlsx(f); },
+ async handleFileUpload(e){ const f=e.target.files?.[0]; if (f) this.readXlsx(f); },
+ async readXlsx(file){
+ this.uploadError=null; this.loading=true;
+ try{
+ await this.loadXLSX();
+ const arr = await new Promise((resolve,reject)=>{ const r=new FileReader(); r.onload=ev=>resolve(new Uint8Array(ev.target.result)); r.onerror=()=>reject(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.loading=false; this.step=1; }
+ },
+ async loadXLSX(){
+ if (window.XLSX) return;
+ await new Promise((res,rej)=>{ const s=document.createElement('script'); s.src='https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js'; s.onload=res; s.onerror=()=>rej(new Error('XLSX konnte nicht geladen werden.')); document.head.appendChild(s); });
+ },
+ async startProcessing(){
+ this.loading=true; this.totalRows=this.parsedData.length; this.processedData=[]; this.progress=0; this.currentRow=0;
+ const snApi = `${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?ont_sn=`;
+ const userApi= `${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?username=`;
+ const sesApi = `${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?action2=find_by_current_session&mac=`;
+ 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(snApi+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(sesApi+encodeURIComponent(mac));
+ if (s.ok){ const ses=await s.json(); if (Array.isArray(ses) && ses.length>0){
+ const uname = ses[0];
+ const u=await fetch(`${userApi}${encodeURIComponent(uname)}&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.step=2; 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]);
+ o['Username']=r[this.fetchedKeys.username];
+ o['Kundennummer']=r[this.fetchedKeys.customerNumber];
+ o['Kundenname']=r[this.fetchedKeys.customerName];
+ o['Info']=r[this.fetchedKeys.info];
+ 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');
+ const ts=new Date().toISOString().replace(/[-:.]/g,'').slice(0,14);
+ XLSX.writeFile(wb, `ont_finder_results_${ts}.xlsx`);
+ }catch{ if(window.notify) window.notify('error', 'Fehler beim Erstellen der Excel-Datei.'); }
+ }
+ }
+});
diff --git a/public/js/pages/Radius/RadiusOntParser.js b/public/js/pages/Radius/RadiusOntParser.js
new file mode 100644
index 000000000..5a74dea38
--- /dev/null
+++ b/public/js/pages/Radius/RadiusOntParser.js
@@ -0,0 +1,190 @@
+/* ===== RadiusOntParser.js =====
+ * Styling via gemeinsame ONT CSS; keine Funktionsänderungen, nur UI-Polish.
+ */
+
+Vue.component('radius-ont-parser', {
+ template: `
+
+
+
+
Schritt 1 · Excel (XLSX) Upload
+
Laden Sie eine XLSX-Datei mit Ihren Kundendaten.
+
+
+
+
+
+
Hierhin ziehen oder Datei auswählen
+
+
+
+
+
+
+
Schritt 2 · Spaltenzuordnung
+
+
+
+
{{ field.label }}
+
+
+ {{ header }}
+
+
+
+
+
+ Verarbeitung starten
+ Zurück
+
+
+
+
+
+
Schritt 3 · Ergebnisse
+
+ Neue Excel herunterladen
+ Zurück
+ Neue Verarbeitung
+
+
+
+
+
+
+
+
+ {{ header.label }}
+
+ ONT SN
+
+
+
+
+ {{ row[selectedColumns.kundennummer] }}
+ {{ row[selectedColumns.anschlussstrasse] }}
+ {{ row[selectedColumns.anschlussplz] }}
+ {{ row[selectedColumns.anschlusscity] }}
+ {{ row.ont_sn }}
+
+
+ Keine Daten
+
+
+
+
+
+
+
+
+
+
+
Verarbeitung läuft...
+
Ihre Daten werden Zeile für Zeile analysiert.
+
+
{{ currentRow + 1 }} / {{ totalRows }}
+
+
+
+
+ `,
+ data() {
+ return {
+ 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
+ };
+ },
+ methods: {
+ onDrop(e){ const f = e.dataTransfer.files?.[0]; if (f) this.readXlsx(f); },
+ handleFileUpload(e){ const f = e.target.files?.[0]; if (f) this.readXlsx(f); },
+ async readXlsx(file){
+ await this.loadXLSX();
+ const fr = new FileReader();
+ fr.onload = (e)=>{
+ const data = new Uint8Array(e.target.result);
+ const wb = XLSX.read(data, { type: 'array' });
+ const ws = wb.Sheets[wb.SheetNames[0]];
+ this.parsedData = XLSX.utils.sheet_to_json(ws);
+ this.headers = Object.keys(this.parsedData[0] || {});
+ this.step = 2;
+ };
+ fr.readAsArrayBuffer(file);
+ },
+ async loadXLSX(){
+ if (window.XLSX) return;
+ await new Promise((res, rej)=>{
+ const s=document.createElement('script');
+ s.src='https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js';
+ s.onload=res; s.onerror=rej; document.head.appendChild(s);
+ });
+ },
+ async startProcessing(){
+ this.loading = true; this.totalRows = this.parsedData.length;
+ const processed = [];
+
+ const dataToProcess = this.parsedData;
+
+ loop:
+ for (let i=0; isetTimeout(r,20));
+ }
+ this.loading=false; this.processedData = processed; this.step = 3;
+ },
+ 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)); }
+ }
+});
diff --git a/public/js/pages/Radius/RadiusUsers.js b/public/js/pages/Radius/RadiusUsers.js
new file mode 100644
index 000000000..96cfd6445
--- /dev/null
+++ b/public/js/pages/Radius/RadiusUsers.js
@@ -0,0 +1,309 @@
+/* ===== RadiusUsers.js =====
+ * Labels entfernt; Platzhalter vereinheitlicht; Online-Status Info; Copy-Tooltips vereinheitlicht;
+ * Modal zeigt Skeleton mit Überschriften.
+ */
+
+Vue.component('radius-users', {
+ template: `
+
+
+
+
+
+
+
+
+ z.B. nat* für lazy Suche
+
+
+
+
+
+
+
z.B. =100.64.32.250 für exakte Suche
+
+
+
+
+
+
+
+
+
+
+ Online-Status abfragen
+
+
+
+
+
+
+
+
+
+
+
+
+ Suchen
+
+
+
+
+
+
+
+
+
+
+
+
Beginnen Sie Ihre Suche, indem Sie Filter eingeben.
+
+
+
+
Keine Ergebnisse für Ihre Suche gefunden.
+
+
+
+
+
+ Suche läuft...
+ {{ radiusUsers.length }} Treffer gefunden
+
+
+
+
+
+
+
+
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() {
+ return {
+ window: window,
+ billAddrDisplay: '', billAddrCustnum: '', username: '', ip: '', info: '',
+ radiusUsers: [],
+ checkOnlineState: false,
+ isLoading: false,
+ showRadacctModal: false,
+ radacctData: null,
+ searchCount: 0,
+ hasSearched: false,
+ visibleCount: 50,
+ observer: null,
+ };
+ },
+ computed: {
+ hasFilters() {
+ return this.billAddrDisplay || this.username || this.ip || this.info;
+ },
+ visibleUsers() {
+ return this.radiusUsers.slice(0, this.visibleCount);
+ }
+ },
+ mounted() {
+ this.observer = new IntersectionObserver(([entry]) => {
+ if (entry && entry.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() {
+ // Re-observe if the table view is re-rendered
+ if (this.observer && this.$refs.sentinel) {
+ this.observer.disconnect();
+ this.observer.observe(this.$refs.sentinel);
+ }
+ },
+ methods: {
+ onAddrSelect({ custnum, display }) { this.billAddrCustnum = custnum || ''; this.billAddrDisplay = display || ''; },
+ async loadRadiusUsers() {
+ this.isLoading = true;
+ this.radiusUsers = [];
+ this.hasSearched = true;
+ this.visibleCount = 50; // Reset visible count on new search
+ try {
+ const params = new URLSearchParams({
+ username: this.username || '',
+ info: this.info || '',
+ custnum: this.billAddrCustnum || '',
+ ip: this.ip || ''
+ });
+ const res = await fetch(`${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?${params.toString()}`);
+ if (res.ok) {
+ const users = await res.json();
+ if (Array.isArray(users) && users.length < 6) this.checkOnlineState = true;
+ this.radiusUsers = Array.isArray(users) ? users : [];
+ }
+ } catch (e) { console.error(e); }
+ this.isLoading = false;
+ this.searchCount++;
+ },
+ async fetchRadacctData(username) {
+ this.showRadacctModal = true; this.radacctData = null;
+ try {
+ const params = new URLSearchParams({ action2: 'fetchRadacct', username });
+ const res = await fetch(`${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?${params.toString()}`);
+ if (res.ok) this.radacctData = await res.json();
+ } catch (e) { console.error(e); this.radacctData = {}; }
+ },
+ async copy(text, type) {
+ await window.RadiusUtils.copyToClipboard(text);
+ if (window.notify) {
+ window.notify('success', `${type} kopiert!`);
+ }
+ },
+ clearFilters() {
+ this.billAddrDisplay = '';
+ this.billAddrCustnum = '';
+ this.username = '';
+ this.ip = '';
+ this.info = '';
+ this.radiusUsers = [];
+ this.hasSearched = false;
+ },
+ loadMore() {
+ if (this.visibleCount < this.radiusUsers.length) {
+ this.visibleCount += 50;
+ }
+ }
+ }
+});
\ No newline at end of file