修复错误
This commit is contained in:
@@ -274,44 +274,46 @@
|
||||
<div class="eyebrow">Xboard Admin Plugin</div>
|
||||
<h1>All Users Online IP Monitor</h1>
|
||||
<div class="subtitle">
|
||||
This plugin page is rendered on the server side for administrators. It reads live device state from Xboard nodes and shows each user's current online IP count and active IP list without relying on browser-stored admin tokens.
|
||||
This page is for Xboard administrators. It reads the current admin token from Xboard browser storage, then loads live device state from Xboard nodes and shows each user's current online IP count and active IP list.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="error-box" class="empty" style="display:none; background: var(--danger-soft); border-style: solid; color: #991b1b;"></div>
|
||||
|
||||
<section class="summary">
|
||||
<article class="card stat">
|
||||
<div class="stat-label">Users On Current Page</div>
|
||||
<div class="stat-value">{{ $summary['page_users'] }}</div>
|
||||
<div class="stat-value" id="page-users">0</div>
|
||||
<div class="stat-note">Rows currently displayed after filtering</div>
|
||||
</article>
|
||||
<article class="card stat">
|
||||
<div class="stat-label">Users With Online IP</div>
|
||||
<div class="stat-value">{{ $summary['users_with_online_ip'] }}</div>
|
||||
<div class="stat-value" id="users-with-ip">0</div>
|
||||
<div class="stat-note">Users on this page whose live IP count is greater than zero</div>
|
||||
</article>
|
||||
<article class="card stat">
|
||||
<div class="stat-label">Total Online IP Count</div>
|
||||
<div class="stat-value">{{ $summary['total_online_ips'] }}</div>
|
||||
<div class="stat-value" id="total-online-ips">0</div>
|
||||
<div class="stat-note">Sum of all active IPs on the current page</div>
|
||||
</article>
|
||||
<article class="card stat">
|
||||
<div class="stat-label">Current Page</div>
|
||||
<div class="stat-value">{{ $summary['current_page'] }}</div>
|
||||
<div class="stat-note">Page {{ $users->currentPage() }} of {{ $users->lastPage() }}</div>
|
||||
<div class="stat-value" id="current-page">1</div>
|
||||
<div class="stat-note" id="page-note">Page 1 of 1</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<form class="toolbar" method="GET" action="">
|
||||
<input class="input" name="keyword" type="text" value="{{ $filters['keyword'] }}" placeholder="Search by user ID or email">
|
||||
<select class="select" name="per_page">
|
||||
<section class="toolbar">
|
||||
<input class="input" id="keyword" type="text" placeholder="Search by user ID or email">
|
||||
<select class="select" id="per-page">
|
||||
@foreach ([20, 50, 100] as $size)
|
||||
<option value="{{ $size }}" @selected((int) $filters['per_page'] === $size)>{{ $size }} / page</option>
|
||||
<option value="{{ $size }}" @selected((int) $defaultPageSize === $size)>{{ $size }} / page</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button class="btn primary" type="submit">Search</button>
|
||||
<a class="btn" href="{{ request()->url() }}">Reset</a>
|
||||
<button class="btn primary" id="search-btn" type="button">Search</button>
|
||||
<button class="btn" id="reset-btn" type="button">Reset</button>
|
||||
<a class="btn" href="{{ $adminHomeUrl }}">Back Admin</a>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card panel">
|
||||
<div class="table-wrap">
|
||||
@@ -327,55 +329,228 @@
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($users as $user)
|
||||
<tr>
|
||||
<td>{{ $user->id }}</td>
|
||||
<td>{{ $user->email }}</td>
|
||||
<td>{{ $user->subscription_name }}</td>
|
||||
<td>
|
||||
<span class="count {{ ($user->online_count_live ?? 0) > 0 ? '' : 'zero' }}">
|
||||
{{ $user->online_count_live ?? 0 }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (!empty($user->online_devices))
|
||||
<div class="ips">
|
||||
@foreach ($user->online_devices as $ip)
|
||||
<span class="ip">{{ $ip }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<span class="muted">No active IP</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $user->last_online_text }}</td>
|
||||
<td>{{ $user->created_text }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<div class="empty">No users found for the current filter.</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
<tbody id="table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div>Total {{ $users->total() }} users, showing page {{ $users->currentPage() }} / {{ $users->lastPage() }}</div>
|
||||
<div id="footer-text">Total 0 users, showing page 1 / 1</div>
|
||||
<div class="pager">
|
||||
@php
|
||||
$prevUrl = $users->previousPageUrl();
|
||||
$nextUrl = $users->nextPageUrl();
|
||||
@endphp
|
||||
<a class="btn" href="{{ $prevUrl ?: '#' }}" aria-disabled="{{ $prevUrl ? 'false' : 'true' }}">Prev</a>
|
||||
<a class="btn" href="{{ $nextUrl ?: '#' }}" aria-disabled="{{ $nextUrl ? 'false' : 'true' }}">Next</a>
|
||||
<button class="btn" id="prev-btn" type="button" aria-disabled="true">Prev</button>
|
||||
<button class="btn" id="next-btn" type="button" aria-disabled="true">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const API_ENDPOINT = @json($apiEndpoint);
|
||||
const state = {
|
||||
page: 1,
|
||||
perPage: {{ (int) $defaultPageSize }},
|
||||
lastPage: 1,
|
||||
keyword: ''
|
||||
};
|
||||
|
||||
function setError(message) {
|
||||
const box = document.getElementById('error-box');
|
||||
box.style.display = 'block';
|
||||
box.textContent = message;
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
const box = document.getElementById('error-box');
|
||||
box.style.display = 'none';
|
||||
box.textContent = '';
|
||||
}
|
||||
|
||||
function normalizeToken(candidate) {
|
||||
if (typeof candidate !== 'string') return '';
|
||||
const trimmed = candidate.trim().replace(/^"|"$/g, '');
|
||||
if (!trimmed) return '';
|
||||
if (/^Bearer\s+/i.test(trimmed)) return trimmed;
|
||||
return 'Bearer ' + trimmed;
|
||||
}
|
||||
|
||||
function parseStoredToken(rawValue) {
|
||||
if (!rawValue || typeof rawValue !== 'string') return '';
|
||||
const trimmed = rawValue.trim();
|
||||
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const nested = parsed.value || parsed.token || parsed.access_token || parsed.accessToken || '';
|
||||
return normalizeToken(nested);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeToken(trimmed);
|
||||
}
|
||||
|
||||
function getAdminAuthorization() {
|
||||
const keys = [
|
||||
'XBOARD_ACCESS_TOKEN',
|
||||
'Xboard_access_token',
|
||||
'Xboard_ACCESS_TOKEN',
|
||||
'xboard_access_token'
|
||||
];
|
||||
|
||||
const storages = [window.localStorage, window.sessionStorage];
|
||||
for (const storage of storages) {
|
||||
if (!storage) continue;
|
||||
for (const key of keys) {
|
||||
const token = parseStoredToken(storage.getItem(key));
|
||||
if (token && token !== 'Bearer ') {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function resetTable() {
|
||||
document.getElementById('page-users').textContent = '0';
|
||||
document.getElementById('users-with-ip').textContent = '0';
|
||||
document.getElementById('total-online-ips').textContent = '0';
|
||||
document.getElementById('current-page').textContent = String(state.page);
|
||||
document.getElementById('page-note').textContent = 'Page ' + state.page + ' of ' + state.lastPage;
|
||||
document.getElementById('footer-text').textContent = 'Total 0 users, showing page ' + state.page + ' / ' + state.lastPage;
|
||||
document.getElementById('prev-btn').setAttribute('aria-disabled', 'true');
|
||||
document.getElementById('next-btn').setAttribute('aria-disabled', 'true');
|
||||
document.getElementById('table-body').innerHTML = '<tr><td colspan="7"><div class="empty">No data available.</div></td></tr>';
|
||||
}
|
||||
|
||||
function renderRows(list) {
|
||||
const body = document.getElementById('table-body');
|
||||
body.innerHTML = '';
|
||||
|
||||
if (!Array.isArray(list) || !list.length) {
|
||||
body.innerHTML = '<tr><td colspan="7"><div class="empty">No users found for the current filter.</div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.forEach(function (item) {
|
||||
const row = document.createElement('tr');
|
||||
const ips = Array.isArray(item.online_devices) ? item.online_devices : [];
|
||||
const ipHtml = ips.length
|
||||
? '<div class="ips">' + ips.map(function (ip) {
|
||||
return '<span class="ip">' + ip + '</span>';
|
||||
}).join('') + '</div>'
|
||||
: '<span class="muted">No active IP</span>';
|
||||
|
||||
row.innerHTML =
|
||||
'<td>' + item.id + '</td>' +
|
||||
'<td>' + item.email + '</td>' +
|
||||
'<td>' + item.subscription_name + '</td>' +
|
||||
'<td><span class="count ' + (item.online_count > 0 ? '' : 'zero') + '">' + item.online_count + '</span></td>' +
|
||||
'<td>' + ipHtml + '</td>' +
|
||||
'<td>' + item.last_online_text + '</td>' +
|
||||
'<td>' + item.created_text + '</td>';
|
||||
body.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPayload(payload) {
|
||||
const summary = payload.summary || {};
|
||||
const pagination = payload.pagination || {};
|
||||
|
||||
state.lastPage = Number(pagination.last_page || 1);
|
||||
|
||||
document.getElementById('page-users').textContent = String(summary.page_users || 0);
|
||||
document.getElementById('users-with-ip').textContent = String(summary.users_with_online_ip || 0);
|
||||
document.getElementById('total-online-ips').textContent = String(summary.total_online_ips || 0);
|
||||
document.getElementById('current-page').textContent = String(summary.current_page || state.page);
|
||||
document.getElementById('page-note').textContent = 'Page ' + (pagination.current || state.page) + ' of ' + state.lastPage;
|
||||
document.getElementById('footer-text').textContent = 'Total ' + (pagination.total || 0) + ' users, showing page ' + (pagination.current || state.page) + ' / ' + state.lastPage;
|
||||
|
||||
document.getElementById('prev-btn').setAttribute('aria-disabled', state.page <= 1 ? 'true' : 'false');
|
||||
document.getElementById('next-btn').setAttribute('aria-disabled', state.page >= state.lastPage ? 'true' : 'false');
|
||||
|
||||
renderRows(payload.list || []);
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const authorization = getAdminAuthorization();
|
||||
if (!authorization) {
|
||||
resetTable();
|
||||
setError('No usable admin login token was found in Xboard browser storage. Please open the Xboard admin homepage first, complete login there, then refresh this page.');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(API_ENDPOINT, window.location.origin);
|
||||
url.searchParams.set('page', String(state.page));
|
||||
url.searchParams.set('per_page', String(state.perPage));
|
||||
if (state.keyword) {
|
||||
url.searchParams.set('keyword', state.keyword);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': authorization
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result || typeof result !== 'object') {
|
||||
throw new Error('Request failed');
|
||||
}
|
||||
|
||||
clearError();
|
||||
renderPayload(result.data || {});
|
||||
} catch (error) {
|
||||
resetTable();
|
||||
setError('Failed to load admin user online IP data. Please confirm you are logged in to the Xboard admin panel and the plugin is enabled.');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('search-btn').addEventListener('click', function () {
|
||||
state.page = 1;
|
||||
state.keyword = document.getElementById('keyword').value.trim();
|
||||
state.perPage = Number(document.getElementById('per-page').value || {{ (int) $defaultPageSize }});
|
||||
loadData();
|
||||
});
|
||||
|
||||
document.getElementById('reset-btn').addEventListener('click', function () {
|
||||
state.page = 1;
|
||||
state.keyword = '';
|
||||
state.perPage = {{ (int) $defaultPageSize }};
|
||||
document.getElementById('keyword').value = '';
|
||||
document.getElementById('per-page').value = String(state.perPage);
|
||||
loadData();
|
||||
});
|
||||
|
||||
document.getElementById('prev-btn').addEventListener('click', function () {
|
||||
if (state.page <= 1) return;
|
||||
state.page -= 1;
|
||||
loadData();
|
||||
});
|
||||
|
||||
document.getElementById('next-btn').addEventListener('click', function () {
|
||||
if (state.page >= state.lastPage) return;
|
||||
state.page += 1;
|
||||
loadData();
|
||||
});
|
||||
|
||||
document.getElementById('keyword').addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
document.getElementById('search-btn').click();
|
||||
}
|
||||
});
|
||||
|
||||
resetTable();
|
||||
loadData();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user