534 lines
18 KiB
PHP
534 lines
18 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>User Status</title>
|
|
<style>
|
|
:root {
|
|
--bg: #f5f1e8;
|
|
--panel: rgba(255, 255, 255, 0.86);
|
|
--line: #ded5c3;
|
|
--text: #2f271d;
|
|
--muted: #756957;
|
|
--accent: #1f7a5a;
|
|
--accent-soft: rgba(31, 122, 90, 0.1);
|
|
--warn: #9a5b29;
|
|
--shadow: 0 22px 60px rgba(73, 54, 27, 0.12);
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
font-family: Georgia, "Times New Roman", serif;
|
|
color: var(--text);
|
|
background:
|
|
radial-gradient(circle at top left, rgba(255, 255, 255, 0.82), transparent 32%),
|
|
linear-gradient(145deg, #efe5d4 0%, #faf7f1 46%, #ebe1d0 100%);
|
|
}
|
|
|
|
.wrap {
|
|
max-width: 1100px;
|
|
margin: 0 auto;
|
|
padding: 28px 20px 40px;
|
|
}
|
|
|
|
.hero {
|
|
margin-bottom: 22px;
|
|
}
|
|
|
|
.eyebrow {
|
|
font-size: 12px;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--muted);
|
|
}
|
|
|
|
h1 {
|
|
margin: 10px 0 10px;
|
|
font-size: clamp(32px, 5vw, 58px);
|
|
line-height: 1;
|
|
}
|
|
|
|
.subtitle {
|
|
max-width: 760px;
|
|
font-size: 16px;
|
|
line-height: 1.75;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
gap: 16px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.card {
|
|
background: var(--panel);
|
|
border: 1px solid rgba(222, 213, 195, 0.92);
|
|
border-radius: 24px;
|
|
box-shadow: var(--shadow);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.stat {
|
|
padding: 22px;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.stat-value {
|
|
margin-top: 10px;
|
|
font-size: 34px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.stat-note {
|
|
margin-top: 10px;
|
|
font-size: 14px;
|
|
line-height: 1.6;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.two-col {
|
|
display: grid;
|
|
grid-template-columns: 1.15fr 0.85fr;
|
|
gap: 18px;
|
|
}
|
|
|
|
.panel {
|
|
padding: 22px;
|
|
}
|
|
|
|
.panel-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.panel-title {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px 12px;
|
|
border-radius: 999px;
|
|
background: var(--accent-soft);
|
|
color: var(--accent);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.list {
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
|
|
.row {
|
|
padding: 16px;
|
|
border-radius: 18px;
|
|
background: rgba(255, 255, 255, 0.7);
|
|
border: 1px solid rgba(222, 213, 195, 0.8);
|
|
}
|
|
|
|
.row-title {
|
|
font-size: 17px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.row-meta {
|
|
margin-top: 8px;
|
|
font-size: 14px;
|
|
line-height: 1.65;
|
|
color: var(--muted);
|
|
word-break: break-all;
|
|
}
|
|
|
|
.empty,
|
|
.error {
|
|
padding: 18px;
|
|
border-radius: 18px;
|
|
font-size: 14px;
|
|
line-height: 1.65;
|
|
}
|
|
|
|
.empty {
|
|
background: rgba(255, 255, 255, 0.55);
|
|
border: 1px dashed var(--line);
|
|
color: var(--muted);
|
|
}
|
|
|
|
.error {
|
|
background: rgba(154, 91, 41, 0.08);
|
|
border: 1px solid rgba(154, 91, 41, 0.18);
|
|
color: var(--warn);
|
|
margin-bottom: 18px;
|
|
}
|
|
|
|
.hint {
|
|
margin-top: 16px;
|
|
color: var(--warn);
|
|
font-size: 14px;
|
|
line-height: 1.7;
|
|
}
|
|
|
|
.actions {
|
|
margin-top: 18px;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 12px;
|
|
}
|
|
|
|
.btn {
|
|
border: 0;
|
|
cursor: pointer;
|
|
border-radius: 999px;
|
|
padding: 10px 16px;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
background: linear-gradient(135deg, #1f7a5a, #2a936d);
|
|
}
|
|
|
|
.btn.secondary {
|
|
color: var(--text);
|
|
background: rgba(255, 255, 255, 0.78);
|
|
border: 1px solid var(--line);
|
|
}
|
|
|
|
@media (max-width: 860px) {
|
|
.two-col {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div class="wrap">
|
|
<section class="hero">
|
|
<div class="eyebrow">Xboard User Status</div>
|
|
<h1>User Status</h1>
|
|
<div class="subtitle">
|
|
This page is provided by the User Online Devices plugin. After login, it reads your current token from browser storage and shows your own online IP list, online count and account status.
|
|
</div>
|
|
</section>
|
|
|
|
<div id="error-box" class="error" style="display:none;"></div>
|
|
|
|
<section class="grid">
|
|
<article class="card stat">
|
|
<div class="stat-label">User</div>
|
|
<div class="stat-value" id="user-email">-</div>
|
|
<div class="stat-note" id="user-id">Please log in first</div>
|
|
</article>
|
|
<article class="card stat">
|
|
<div class="stat-label">Online IP Count</div>
|
|
<div class="stat-value" id="online-count">-</div>
|
|
<div class="stat-note">Unique real-time IP count reported by Xboard nodes</div>
|
|
</article>
|
|
<article class="card stat">
|
|
<div class="stat-label">Last Online</div>
|
|
<div class="stat-value" id="last-online">-</div>
|
|
<div class="stat-note">Last device state update written by Xboard</div>
|
|
</article>
|
|
<article class="card stat">
|
|
<div class="stat-label">Subscription</div>
|
|
<div class="stat-value" id="plan-name">-</div>
|
|
<div class="stat-note" id="traffic-note">-</div>
|
|
</article>
|
|
</section>
|
|
|
|
<section class="two-col">
|
|
<article class="card panel">
|
|
<div class="panel-head">
|
|
<div class="panel-title">Online IP List</div>
|
|
<div class="badge" id="refresh-badge">Auto Refresh</div>
|
|
</div>
|
|
<div class="list" id="ip-list"></div>
|
|
<div class="hint">
|
|
Current Xboard device state is counted by unique IP. If one device changes network, it may appear as a different online IP.
|
|
</div>
|
|
</article>
|
|
|
|
<article class="card panel">
|
|
<div class="panel-head">
|
|
<div class="panel-title">Active Sessions</div>
|
|
<div class="badge" id="session-badge">Checking</div>
|
|
</div>
|
|
<div class="list" id="session-list"></div>
|
|
<div class="actions">
|
|
<button class="btn" id="reload-btn" type="button">Reload</button>
|
|
<button class="btn secondary" id="home-btn" type="button">Back Home</button>
|
|
</div>
|
|
</article>
|
|
</section>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
const ENDPOINTS = {
|
|
summary: '/api/v1/user-online-devices/summary',
|
|
info: '/api/v1/user/info',
|
|
subscribe: '/api/v1/user/getSubscribe'
|
|
};
|
|
const REFRESH_MS = 30000;
|
|
|
|
function collectStringCandidates(value, bucket) {
|
|
if (!value || typeof value !== 'string') return;
|
|
|
|
const trimmed = value.trim().replace(/^"|"$/g, '');
|
|
if (!trimmed) return;
|
|
|
|
bucket.add(trimmed);
|
|
|
|
if (trimmed.startsWith('Bearer ')) {
|
|
bucket.add(trimmed.slice(7).trim());
|
|
}
|
|
|
|
if (trimmed[0] === '{' || trimmed[0] === '[') {
|
|
try {
|
|
collectFromUnknown(JSON.parse(trimmed), bucket);
|
|
} catch (error) {
|
|
}
|
|
}
|
|
}
|
|
|
|
function collectFromUnknown(input, bucket) {
|
|
if (!input) return;
|
|
|
|
if (typeof input === 'string') {
|
|
collectStringCandidates(input, bucket);
|
|
return;
|
|
}
|
|
|
|
if (Array.isArray(input)) {
|
|
input.forEach(function (item) {
|
|
collectFromUnknown(item, bucket);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (typeof input === 'object') {
|
|
Object.keys(input).forEach(function (key) {
|
|
collectFromUnknown(input[key], bucket);
|
|
});
|
|
}
|
|
}
|
|
|
|
function collectTokensFromStorage(storage, bucket) {
|
|
if (!storage) return;
|
|
|
|
for (let i = 0; i < storage.length; i += 1) {
|
|
const key = storage.key(i);
|
|
if (!key) continue;
|
|
collectStringCandidates(storage.getItem(key), bucket);
|
|
}
|
|
}
|
|
|
|
function buildTokenCandidates() {
|
|
const bucket = new Set();
|
|
collectTokensFromStorage(window.localStorage, bucket);
|
|
collectTokensFromStorage(window.sessionStorage, bucket);
|
|
|
|
return Array.from(bucket)
|
|
.map(function (token) {
|
|
return token.startsWith('Bearer ') ? token : 'Bearer ' + token;
|
|
})
|
|
.filter(function (token) {
|
|
const raw = token.replace(/^Bearer\s+/i, '').trim();
|
|
return raw.length >= 20 && !/\s/.test(raw);
|
|
});
|
|
}
|
|
|
|
async function pickAuthorization() {
|
|
const candidates = buildTokenCandidates();
|
|
|
|
for (const authorization of candidates) {
|
|
try {
|
|
const response = await fetch(ENDPOINTS.info, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'Authorization': authorization
|
|
},
|
|
credentials: 'same-origin'
|
|
});
|
|
|
|
if (response.ok) {
|
|
return authorization;
|
|
}
|
|
} catch (error) {
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function formatTime(timestamp) {
|
|
if (!timestamp) return '-';
|
|
const date = new Date(timestamp * 1000);
|
|
const pad = function (value) { return String(value).padStart(2, '0'); };
|
|
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds());
|
|
}
|
|
|
|
function formatTraffic(bytes) {
|
|
if (!bytes && bytes !== 0) return '-';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let value = Number(bytes);
|
|
let unitIndex = 0;
|
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
value = value / 1024;
|
|
unitIndex += 1;
|
|
}
|
|
return value.toFixed(value >= 100 || unitIndex === 0 ? 0 : 2) + ' ' + units[unitIndex];
|
|
}
|
|
|
|
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 = '';
|
|
}
|
|
|
|
async function getJson(url, authorization) {
|
|
const response = await fetch(url, {
|
|
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');
|
|
}
|
|
return result.data || {};
|
|
}
|
|
|
|
function renderInfo(info) {
|
|
document.getElementById('user-email').textContent = info.email || '-';
|
|
document.getElementById('user-id').textContent = info.email ? ('UUID: ' + (info.uuid || '-')) : 'Please log in first';
|
|
}
|
|
|
|
function renderSubscribe(subscribe) {
|
|
document.getElementById('plan-name').textContent = subscribe.plan && subscribe.plan.name ? subscribe.plan.name : 'No Plan';
|
|
const used = (subscribe.u || 0) + (subscribe.d || 0);
|
|
const total = subscribe.transfer_enable || 0;
|
|
document.getElementById('traffic-note').textContent = 'Used ' + formatTraffic(used) + ' / ' + formatTraffic(total);
|
|
}
|
|
|
|
function renderSummary(summary) {
|
|
document.getElementById('online-count').textContent = String(summary.user ? summary.user.online_count : 0);
|
|
document.getElementById('last-online').textContent = formatTime(summary.user ? summary.user.last_online_at : null);
|
|
document.getElementById('refresh-badge').textContent = 'Refresh every ' + ((summary.meta && summary.meta.refresh_interval_seconds) || 30) + 's';
|
|
document.getElementById('session-badge').textContent = summary.meta && summary.meta.show_active_sessions ? 'Enabled' : 'Disabled';
|
|
|
|
const ipList = document.getElementById('ip-list');
|
|
ipList.innerHTML = '';
|
|
const devices = Array.isArray(summary.online_devices) ? summary.online_devices : [];
|
|
if (!devices.length) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'empty';
|
|
empty.textContent = 'No online IP detected currently.';
|
|
ipList.appendChild(empty);
|
|
} else {
|
|
devices.forEach(function (device) {
|
|
const row = document.createElement('div');
|
|
row.className = 'row';
|
|
row.innerHTML = '<div class=\"row-title\">' + device.name + '</div><div class=\"row-meta\">IP: ' + device.ip + '</div>';
|
|
ipList.appendChild(row);
|
|
});
|
|
}
|
|
|
|
const sessionList = document.getElementById('session-list');
|
|
sessionList.innerHTML = '';
|
|
const sessions = Array.isArray(summary.active_sessions) ? summary.active_sessions : [];
|
|
if (!sessions.length) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'empty';
|
|
empty.textContent = summary.meta && summary.meta.show_active_sessions
|
|
? 'No active session entries available.'
|
|
: 'Session display is disabled by plugin config.';
|
|
sessionList.appendChild(empty);
|
|
} else {
|
|
sessions.forEach(function (session) {
|
|
const row = document.createElement('div');
|
|
row.className = 'row';
|
|
row.innerHTML =
|
|
'<div class=\"row-title\">' + (session.device || 'Unknown Session') + '</div>' +
|
|
'<div class=\"row-meta\">' +
|
|
'Session ID: ' + session.id + '<br>' +
|
|
'Last Used: ' + formatTime(session.last_used_at) + '<br>' +
|
|
'Created: ' + formatTime(session.created_at) + '<br>' +
|
|
'Expires: ' + formatTime(session.expires_at) +
|
|
'</div>';
|
|
sessionList.appendChild(row);
|
|
});
|
|
}
|
|
}
|
|
|
|
async function loadPageData() {
|
|
const authorization = await pickAuthorization();
|
|
if (!authorization) {
|
|
setError('No usable login token was found in browser storage. Please open the Xboard homepage first, complete login there, then refresh this page.');
|
|
return;
|
|
}
|
|
|
|
clearError();
|
|
|
|
try {
|
|
const results = await Promise.all([
|
|
getJson(ENDPOINTS.summary, authorization),
|
|
getJson(ENDPOINTS.info, authorization),
|
|
getJson(ENDPOINTS.subscribe, authorization)
|
|
]);
|
|
|
|
renderSummary(results[0]);
|
|
renderInfo(results[1]);
|
|
renderSubscribe(results[2]);
|
|
} catch (error) {
|
|
setError('Failed to load your user status. Please confirm the plugin is enabled and your login is still valid.');
|
|
}
|
|
}
|
|
|
|
document.getElementById('reload-btn').addEventListener('click', function () {
|
|
loadPageData();
|
|
});
|
|
|
|
document.getElementById('home-btn').addEventListener('click', function () {
|
|
window.location.href = '/';
|
|
});
|
|
|
|
loadPageData();
|
|
window.setInterval(loadPageData, REFRESH_MS);
|
|
})();
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|