2026-07-23 11:12:10 +02:00
@page "/admin"
@rendermode InteractiveServer
@inject IRadiusService RadiusService
@inject AdminAuthService AuthService
@inject NavigationManager NavManager
<div style="max-width: 1280px; margin: 0 auto;">
@if (!AuthService.IsAuthenticated)
{
<div class="glass-card" style="text-align: center; padding: 3rem;">
<div class="spinner-border text-primary" role="status"></div>
<p style="margin-top: 1rem; color: var(--text-muted);">Verifying Administrator Session...</p>
</div>
}
else
{
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 2rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.02em;">
<i class="bi bi-speedometer2" style="color: var(--primary);"></i> RADIUS Controller Dashboard
</h1>
<p style="color: var(--text-muted); font-size: 0.95rem;">
Manage guest bandwidth caps, session durations, and monitor live PostgreSQL accounting.
</p>
</div>
2026-07-24 12:09:46 +02:00
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button class="btn-sm btn-secondary" @onclick="RefreshData" disabled="@isLoading">
<i class="bi bi-arrow-clockwise"></i> Refresh Data
</button>
<button class="btn-sm btn-danger" @onclick="LogoutAdmin">
<i class="bi bi-box-arrow-right"></i> Logout (@AuthService.CurrentAdminUsername)
</button>
</div>
2026-07-23 11:12:10 +02:00
</div>
@if (notificationMessage != null)
{
<div class="glass-card" style="padding: 1rem 1.25rem; margin-bottom: 1.5rem; border-color: rgba(20, 184, 166, 0.4); background: rgba(20, 184, 166, 0.1);">
<i class="bi bi-info-circle-fill" style="color: var(--accent-teal);"></i> @notificationMessage
</div>
}
<!-- Top Statistics Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon primary">
<i class="bi bi-people-fill"></i>
</div>
<div>
<div class="stat-number">@users.Count</div>
<div class="stat-label">Total Registered Guests</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon emerald">
<i class="bi bi-wifi"></i>
</div>
<div>
<div class="stat-number" style="color: var(--accent-emerald);">@activeSessions.Count</div>
<div class="stat-label">Active Sessions Now</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon teal">
<i class="bi bi-cloud-arrow-down-fill"></i>
</div>
<div>
<div class="stat-number">@FormatTotalDataUsed()</div>
<div class="stat-label">Total Data Consumed</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon amber">
<i class="bi bi-sliders"></i>
</div>
<div>
<div class="stat-number">@(config?.DefaultSessionTimeMinutes ?? 0)m / @(config?.DefaultDataLimitMb ?? 0)MB</div>
<div class="stat-label">Default Policy (Time / Cap)</div>
</div>
</div>
</div>
<!-- Dashboard Navigation Tabs -->
<div class="tab-nav">
<button class="tab-btn @(activeTab == "users" ? "active" : "")" @onclick='() => activeTab = "users"'>
<i class="bi bi-person-lines-fill"></i> Guest Users & Usage (@users.Count)
</button>
<button class="tab-btn @(activeTab == "config" ? "active" : "")" @onclick='() => activeTab = "config"'>
<i class="bi bi-gear-wide-connected"></i> Global RADIUS Settings
</button>
<button class="tab-btn @(activeTab == "active" ? "active" : "")" @onclick='() => activeTab = "active"'>
<i class="bi bi-activity"></i> Live Connections (@activeSessions.Count)
</button>
</div>
<!-- TAB 1: User Management & Accounting Data -->
@if (activeTab == "users")
{
<div class="glass-card">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div style="position: relative; max-width: 320px; width: 100%;">
<input type="text"
class="form-control-glass"
placeholder="Search guest name or username..."
@bind="searchQuery"
@bind:event="oninput"
style="padding-left: 2.5rem; font-size: 0.9rem;" />
<i class="bi bi-search" style="position: absolute; left: 1rem; top: 50%; transform: translateY(-50%); color: var(--text-muted);"></i>
</div>
</div>
<div class="table-responsive">
<table class="custom-table">
<thead>
<tr>
<th>Guest Name</th>
<th>RADIUS Credentials</th>
<th>Status</th>
<th>Session Duration Limit</th>
<th>Data Used / Cap</th>
<th>Total Online Time</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@if (FilteredUsers.Count == 0)
{
<tr>
<td colspan="7" style="text-align: center; color: var(--text-muted); padding: 3rem;">
<i class="bi bi-inbox" style="font-size: 2rem; display: block; margin-bottom: 0.5rem;"></i>
No guest accounts found.
</td>
</tr>
}
@foreach (var user in FilteredUsers)
{
double percentage = user.DataLimitMb > 0 ? Math.Min(100, Math.Round((user.UsedDataMb / user.DataLimitMb) * 100, 1)) : 0;
string fillClass = percentage >= 85 ? "warning" : "normal";
<tr>
<td>
<div style="font-weight: 700;">@user.GuestName</div>
<div style="font-size: 0.75rem; color: var(--text-dim);">
Registered: @user.CreatedAt.ToLocalTime().ToString("MMM dd, yyyy HH:mm")
</div>
</td>
<td>
<div style="font-family: var(--font-mono); font-weight: 600; color: var(--accent-teal);">
@user.Username
</div>
<div style="font-family: var(--font-mono); font-size: 0.8rem; color: var(--text-muted);">
Pwd: <span style="color: var(--accent-purple);">@user.Password</span>
</div>
</td>
<td>
@if (user.IsCurrentlyConnected)
{
<span class="badge badge-emerald"><i class="bi bi-broadcast"></i> Online</span>
}
else if (user.Status == "Revoked")
{
<span class="badge badge-rose"><i class="bi bi-x-circle"></i> Revoked</span>
}
else
{
<span class="badge badge-primary"><i class="bi bi-check-circle"></i> @user.Status</span>
}
</td>
<td>
<strong>@user.SessionTimeMinutes mins</strong>
<div style="font-size: 0.75rem; color: var(--text-dim);">
2026-07-24 18:02:35 +02:00
Expires: @user.CreatedAt.AddMinutes(user.SessionTimeMinutes).ToLocalTime().ToString("MMM dd, HH:mm")
2026-07-23 11:12:10 +02:00
</div>
</td>
<td style="min-width: 180px;">
<div style="display: flex; justify-content: space-between; font-size: 0.85rem; font-weight: 600;">
<span>@user.UsedDataMb MB</span>
<span style="color: var(--text-muted);">/ @user.DataLimitMb MB</span>
</div>
<div class="progress-bar-bg">
<div class="progress-bar-fill @fillClass" style="width: @percentage%;"></div>
</div>
<div style="font-size: 0.725rem; color: var(--text-dim); margin-top: 0.2rem;">
Upload: @FormatMb(user.TotalInputOctets) | Down: @FormatMb(user.TotalOutputOctets)
</div>
</td>
<td>
@FormatSeconds(user.TotalSessionTimeSeconds)
</td>
<td>
<div style="display: flex; gap: 0.35rem;">
<button class="btn-sm btn-secondary" title="Edit Session & Data Limits" @onclick="() => OpenEditModal(user)">
<i class="bi bi-pencil"></i>
</button>
@if (user.Status == "Active")
{
<button class="btn-sm btn-danger" title="Revoke Access" @onclick="() => RevokeUser(user.ProfileId)">
<i class="bi bi-slash-circle"></i>
</button>
}
2026-07-24 12:09:46 +02:00
<button class="btn-sm btn-secondary" style="color: var(--accent-rose);" title="Delete User" @onclick="() => OpenDeleteModal(user)">
2026-07-23 11:12:10 +02:00
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- TAB 2: Global Configuration Settings -->
@if (activeTab == "config")
{
<div class="glass-card" style="max-width: 680px;">
<h3 style="font-size: 1.25rem; font-weight: 700; margin-bottom: 1rem;">
<i class="bi bi-sliders" style="color: var(--primary);"></i> Default RADIUS Policy Settings
</h3>
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 1.5rem;">
These parameters determine the initial session duration limit and total bandwidth quota assigned when a new guest registers.
</p>
<EditForm Model="this" OnValidSubmit="SaveGlobalConfig">
<div class="form-group">
<label class="form-label">Default Session Time (Minutes)</label>
<input type="number"
class="form-control-glass"
@bind="editConfigSessionTime"
min="1"
required />
<span style="font-size: 0.775rem; color: var(--text-dim);">
Translates to RADIUS attribute <code style="color: var(--accent-teal);">Session-Timeout</code> (in seconds) for FreeRADIUS reply.
</span>
</div>
<div class="form-group">
<label class="form-label">Default Data Usage Cap (Megabytes)</label>
<input type="number"
class="form-control-glass"
@bind="editConfigDataLimit"
min="1"
required />
<span style="font-size: 0.775rem; color: var(--text-dim);">
Translates to RADIUS attribute <code style="color: var(--accent-teal);">Max-Octets</code> (in bytes) for FreeRADIUS reply.
</span>
</div>
<button type="submit" class="btn-gradient-primary" style="width: auto; padding: 0.8rem 2rem;">
<i class="bi bi-save-fill"></i> Save Default Policy
</button>
</EditForm>
</div>
}
<!-- TAB 3: Live Active Sessions -->
@if (activeTab == "active")
{
<div class="glass-card">
<h3 style="font-size: 1.25rem; font-weight: 700; margin-bottom: 1rem;">
<i class="bi bi-broadcast" style="color: var(--accent-emerald);"></i> Live Connected Devices
</h3>
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 1.5rem;">
Real-time active RADIUS sessions recorded in PostgreSQL <code style="color: var(--accent-teal);">radacct</code> table.
</p>
<div class="table-responsive">
<table class="custom-table">
<thead>
<tr>
<th>Username</th>
<th>NAS IP Address</th>
<th>Calling Station (MAC)</th>
<th>Framed IP</th>
<th>Start Time</th>
<th>Online Duration</th>
<th>Downloaded / Uploaded</th>
</tr>
</thead>
<tbody>
@if (activeSessions.Count == 0)
{
<tr>
<td colspan="7" style="text-align: center; color: var(--text-muted); padding: 3rem;">
<i class="bi bi-wifi-off" style="font-size: 2rem; display: block; margin-bottom: 0.5rem;"></i>
No active sessions currently online.
</td>
</tr>
}
@foreach (var session in activeSessions)
{
<tr>
<td>
<span style="font-family: var(--font-mono); font-weight: 700; color: var(--accent-teal);">
@session.Username
</span>
</td>
<td>@session.NasIpAddress</td>
<td>
<span style="font-family: var(--font-mono); font-size: 0.85rem;">
@(string.IsNullOrEmpty(session.CallingStationId) ? "N/A" : session.CallingStationId)
</span>
</td>
<td>
<span style="font-family: var(--font-mono); font-size: 0.85rem;">
@(string.IsNullOrEmpty(session.FramedIpAddress) ? "DHCP Assigned" : session.FramedIpAddress)
</span>
</td>
<td>@session.AcctStartTime?.ToLocalTime().ToString("HH:mm:ss MMM dd")</td>
<td>@FormatSeconds(session.AcctSessionTime ?? 0)</td>
<td>
<span style="color: #34d399;">↓ @FormatMb(session.AcctOutputOctets ?? 0) MB</span> /
<span style="color: #60a5fa;">↑ @FormatMb(session.AcctInputOctets ?? 0) MB</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- Edit User Limits Modal Overlay -->
@if (selectedUserForEdit != null)
{
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 1rem;">
<div class="glass-card" style="max-width: 500px; width: 100%; border-color: rgba(99, 102, 241, 0.4);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3 style="font-size: 1.25rem; font-weight: 700;">
<i class="bi bi-pencil-square" style="color: var(--primary);"></i> Edit Guest Policy
</h3>
<button class="btn-sm btn-secondary" @onclick="() => selectedUserForEdit = null">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div style="background: rgba(255,255,255,0.03); padding: 0.85rem; border-radius: var(--radius-sm); margin-bottom: 1.5rem; font-size: 0.9rem;">
<div>Guest: <strong>@selectedUserForEdit.GuestName</strong></div>
<div style="font-family: var(--font-mono); color: var(--accent-teal);">Username: @selectedUserForEdit.Username</div>
</div>
<div class="form-group">
<label class="form-label">Session Time Limit (Minutes)</label>
<input type="number" class="form-control-glass" @bind="editModalSessionTime" min="1" required />
</div>
<div class="form-group">
<label class="form-label">Data Usage Limit (MB)</label>
<input type="number" class="form-control-glass" @bind="editModalDataLimit" min="1" required />
</div>
<div class="form-group">
<label class="form-label">Reset Password (Optional)</label>
<input type="text" class="form-control-glass" placeholder="Leave blank to keep existing password" @bind="editModalNewPassword" />
</div>
<div style="display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1.5rem;">
<button class="btn-sm btn-secondary" @onclick="() => selectedUserForEdit = null">Cancel</button>
<button class="btn-gradient-primary" style="width: auto;" @onclick="SaveUserEdit">
<i class="bi bi-check-lg"></i> Save Changes
</button>
</div>
</div>
</div>
}
2026-07-24 12:09:46 +02:00
<!-- Delete Confirmation Modal Overlay -->
@if (selectedUserForDelete != null)
{
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 1rem;">
<div class="glass-card" style="max-width: 480px; width: 100%; border-color: rgba(244, 63, 94, 0.4);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3 style="font-size: 1.25rem; font-weight: 700; color: var(--accent-rose);">
<i class="bi bi-exclamation-triangle-fill"></i> Delete Guest Account
</h3>
<button class="btn-sm btn-secondary" @onclick="() => selectedUserForDelete = null">
<i class="bi bi-x-lg"></i>
</button>
</div>
<p style="color: var(--text-muted); font-size: 0.95rem; margin-bottom: 1rem;">
Are you sure you want to permanently delete guest account <strong style="color: var(--text-primary);">@selectedUserForDelete.GuestName</strong> (<code style="color: var(--accent-teal);">@selectedUserForDelete.Username</code>)?
</p>
<p style="color: var(--text-dim); font-size: 0.825rem; margin-bottom: 1.5rem;">
This will remove their RADIUS credentials from <code style="color: var(--accent-teal);">radcheck</code> and <code style="color: var(--accent-teal);">radreply</code> tables. This action cannot be undone.
</p>
<div style="display: flex; gap: 0.75rem; justify-content: flex-end;">
<button class="btn-sm btn-secondary" @onclick="() => selectedUserForDelete = null">Cancel</button>
<button class="btn-sm btn-danger" style="padding: 0.5rem 1.25rem;" @onclick="ConfirmDeleteUser">
<i class="bi bi-trash-fill"></i> Delete User
</button>
</div>
</div>
</div>
}
2026-07-23 11:12:10 +02:00
}
</div>
@code {
private bool isLoading = true;
private string activeTab = "users";
private string searchQuery = string.Empty;
private string? notificationMessage;
private SystemConfigDto? config;
private List<UserUsageDto> users = new();
private List<RadAcct> activeSessions = new();
private int editConfigSessionTime;
private long editConfigDataLimit;
private UserUsageDto? selectedUserForEdit;
private int editModalSessionTime;
private long editModalDataLimit;
private string editModalNewPassword = string.Empty;
2026-07-24 12:09:46 +02:00
private UserUsageDto? selectedUserForDelete;
protected override async Task OnAfterRenderAsync(bool firstRender)
2026-07-23 11:12:10 +02:00
{
2026-07-24 12:09:46 +02:00
if (firstRender)
2026-07-23 11:12:10 +02:00
{
2026-07-24 12:09:46 +02:00
await AuthService.InitializeAsync();
if (!AuthService.IsAuthenticated)
{
NavManager.NavigateTo("/admin/login");
return;
}
await LoadDataAsync();
StateHasChanged();
2026-07-23 11:12:10 +02:00
}
2026-07-24 12:09:46 +02:00
}
2026-07-23 11:12:10 +02:00
2026-07-24 12:09:46 +02:00
private async Task LogoutAdmin()
{
await AuthService.LogoutAsync();
NavManager.NavigateTo("/admin/login");
2026-07-23 11:12:10 +02:00
}
private async Task LoadDataAsync()
{
isLoading = true;
try
{
config = await RadiusService.GetSystemConfigAsync();
editConfigSessionTime = config.DefaultSessionTimeMinutes;
editConfigDataLimit = config.DefaultDataLimitMb;
users = await RadiusService.GetAllUsersWithUsageAsync();
activeSessions = await RadiusService.GetActiveSessionsAsync();
}
catch (Exception ex)
{
notificationMessage = "Error loading data: " + ex.Message;
}
finally
{
isLoading = false;
}
}
private async Task RefreshData()
{
notificationMessage = null;
await LoadDataAsync();
ShowNotification("Data refreshed successfully.");
}
private List<UserUsageDto> FilteredUsers
{
get
{
if (string.IsNullOrWhiteSpace(searchQuery)) return users;
string q = searchQuery.Trim().ToLowerInvariant();
return users.Where(u => u.GuestName.ToLowerInvariant().Contains(q) || u.Username.ToLowerInvariant().Contains(q)).ToList();
}
}
private async Task SaveGlobalConfig()
{
try
{
await RadiusService.UpdateSystemConfigAsync(editConfigSessionTime, editConfigDataLimit);
config = await RadiusService.GetSystemConfigAsync();
ShowNotification("Global default session time & data cap policy updated.");
}
catch (Exception ex)
{
ShowNotification("Failed to update policy: " + ex.Message);
}
}
private void OpenEditModal(UserUsageDto user)
{
selectedUserForEdit = user;
editModalSessionTime = user.SessionTimeMinutes;
editModalDataLimit = user.DataLimitMb;
editModalNewPassword = string.Empty;
}
private async Task SaveUserEdit()
{
if (selectedUserForEdit == null) return;
try
{
await RadiusService.UpdateUserLimitsAsync(selectedUserForEdit.ProfileId, editModalSessionTime, editModalDataLimit);
if (!string.IsNullOrWhiteSpace(editModalNewPassword))
{
await RadiusService.ResetUserPasswordAsync(selectedUserForEdit.ProfileId, editModalNewPassword.Trim());
}
selectedUserForEdit = null;
await LoadDataAsync();
ShowNotification("User limits updated successfully.");
}
catch (Exception ex)
{
ShowNotification("Error updating user: " + ex.Message);
}
}
private async Task RevokeUser(int profileId)
{
try
{
await RadiusService.RevokeUserAsync(profileId);
await LoadDataAsync();
ShowNotification("User access revoked.");
}
catch (Exception ex)
{
ShowNotification("Error revoking user: " + ex.Message);
}
}
2026-07-24 12:09:46 +02:00
private void OpenDeleteModal(UserUsageDto user)
2026-07-23 11:12:10 +02:00
{
2026-07-24 12:09:46 +02:00
selectedUserForDelete = user;
}
private async Task ConfirmDeleteUser()
{
if (selectedUserForDelete == null) return;
int profileId = selectedUserForDelete.ProfileId;
selectedUserForDelete = null;
2026-07-23 11:12:10 +02:00
try
{
await RadiusService.DeleteUserAsync(profileId);
await LoadDataAsync();
2026-07-24 12:09:46 +02:00
ShowNotification("User account deleted successfully.");
2026-07-23 11:12:10 +02:00
}
catch (Exception ex)
{
ShowNotification("Error deleting user: " + ex.Message);
}
}
private void ShowNotification(string message)
{
notificationMessage = message;
StateHasChanged();
}
private string FormatTotalDataUsed()
{
long totalOctets = users.Sum(u => u.TotalOctets);
double gb = (double)totalOctets / (1024 * 1024 * 1024);
if (gb >= 1.0)
return $"{Math.Round(gb, 2)} GB";
double mb = (double)totalOctets / (1024 * 1024);
return $"{Math.Round(mb, 1)} MB";
}
private static double FormatMb(long bytes) => Math.Round((double)bytes / (1024 * 1024), 2);
private static string FormatSeconds(long seconds)
{
if (seconds <= 0) return "0s";
TimeSpan t = TimeSpan.FromSeconds(seconds);
if (t.TotalHours >= 1)
return $"{Math.Floor(t.TotalHours)}h {t.Minutes}m";
if (t.TotalMinutes >= 1)
return $"{t.Minutes}m {t.Seconds}s";
return $"{t.Seconds}s";
}
}