整理文件
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
reference/
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
*.zip
|
||||
6
plugins/.gitignore
vendored
6
plugins/.gitignore
vendored
@@ -1,10 +1,4 @@
|
||||
*
|
||||
!.gitignore
|
||||
!AlipayF2f/
|
||||
!Btcpay
|
||||
!Coinbase
|
||||
!Epay
|
||||
!Mgate
|
||||
!Telegram
|
||||
!UserOnlineDevices/
|
||||
!UserOnlineDevices/**
|
||||
|
||||
@@ -5,11 +5,10 @@ namespace Plugin\UserOnlineDevices\Controllers;
|
||||
use App\Http\Controllers\PluginController;
|
||||
use App\Models\User;
|
||||
use App\Services\DeviceStateService;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class UserOnlineDevicesController extends PluginController
|
||||
{
|
||||
@@ -18,231 +17,107 @@ class UserOnlineDevicesController extends PluginController
|
||||
) {
|
||||
}
|
||||
|
||||
public function summary(Request $request)
|
||||
public function index(Request $request): View
|
||||
{
|
||||
if ($error = $this->beforePluginAction()) {
|
||||
return $this->fail($error);
|
||||
abort($error[0], $error[1]);
|
||||
}
|
||||
|
||||
return $this->success($this->buildPayload($request->user()));
|
||||
}
|
||||
$defaultPageSize = (int) $this->getConfig('default_page_size', 20);
|
||||
$pageSize = (int) $request->integer('per_page', $defaultPageSize);
|
||||
$pageSize = in_array($pageSize, [20, 50, 100], true) ? $pageSize : $defaultPageSize;
|
||||
|
||||
public function panelUrl(Request $request)
|
||||
{
|
||||
if ($error = $this->beforePluginAction()) {
|
||||
return $this->fail($error);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$ttlMinutes = max(1, (int) $this->getConfig('signed_url_ttl_minutes', 60));
|
||||
$expiresAt = now()->addMinutes($ttlMinutes);
|
||||
|
||||
return $this->success([
|
||||
'url' => URL::temporarySignedRoute(
|
||||
'user-online-devices.panel',
|
||||
$expiresAt,
|
||||
['user' => $user->id]
|
||||
),
|
||||
'expires_at' => $expiresAt->timestamp,
|
||||
]);
|
||||
}
|
||||
|
||||
public function adminUsersPage(): View
|
||||
{
|
||||
abort_unless($this->isPluginEnabled(), 404);
|
||||
|
||||
$securePath = admin_setting('secure_path', admin_setting('frontend_admin_path', hash('crc32b', config('app.key'))));
|
||||
|
||||
return view('UserOnlineDevices::admin-users', [
|
||||
'apiEndpoint' => url('/api/v2/' . $securePath . '/user-online-devices/users'),
|
||||
'adminHomeUrl' => url('/' . $securePath),
|
||||
]);
|
||||
}
|
||||
|
||||
public function adminUsers(Request $request)
|
||||
{
|
||||
if ($error = $this->beforePluginAction()) {
|
||||
return $this->fail($error);
|
||||
}
|
||||
|
||||
$current = max(1, (int) $request->input('current', 1));
|
||||
$pageSize = min(100, max(10, (int) $request->input('pageSize', 20)));
|
||||
$keyword = trim((string) $request->input('keyword', ''));
|
||||
$keyword = trim((string) $request->query('keyword', ''));
|
||||
|
||||
$query = User::query()
|
||||
->select(['id', 'email', 'last_online_at', 'created_at']);
|
||||
->select([
|
||||
'id',
|
||||
'email',
|
||||
'plan_id',
|
||||
'online_count',
|
||||
'last_online_at',
|
||||
'created_at',
|
||||
])
|
||||
->with(['plan:id,name'])
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('email', 'like', '%' . $keyword . '%');
|
||||
if (is_numeric($keyword)) {
|
||||
|
||||
if (ctype_digit($keyword)) {
|
||||
$builder->orWhere('id', (int) $keyword);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$users = $query
|
||||
->orderByDesc('last_online_at')
|
||||
->orderByDesc('id')
|
||||
->paginate($pageSize, ['*'], 'page', $current);
|
||||
/** @var LengthAwarePaginator $paginator */
|
||||
$paginator = $query->paginate($pageSize)->appends($request->query());
|
||||
|
||||
return $this->success($this->buildAdminUsersPayload($users));
|
||||
}
|
||||
$userIds = $paginator->getCollection()->pluck('id')->all();
|
||||
$devicesByUser = $this->deviceStateService->getUsersDevices($userIds);
|
||||
|
||||
public function panel(Request $request, int $user): View
|
||||
{
|
||||
abort_unless($this->isPluginEnabled(), 404);
|
||||
$paginator->setCollection(
|
||||
$paginator->getCollection()->map(function (User $user) use ($devicesByUser) {
|
||||
$ips = array_values($devicesByUser[$user->id] ?? []);
|
||||
sort($ips);
|
||||
|
||||
$targetUser = User::query()->findOrFail($user);
|
||||
$payload = $this->buildPayload($targetUser);
|
||||
$ttlMinutes = max(1, (int) $this->getConfig('signed_url_ttl_minutes', 60));
|
||||
$user->online_devices = $ips;
|
||||
$user->online_count_live = count($ips);
|
||||
$user->subscription_name = $user->plan?->name ?: 'No subscription';
|
||||
$user->last_online_text = $this->formatDateTime($user->last_online_at);
|
||||
$user->created_text = $this->formatDateTime($user->created_at);
|
||||
|
||||
return view('UserOnlineDevices::panel', [
|
||||
'payload' => $payload,
|
||||
'snapshotUrl' => URL::temporarySignedRoute(
|
||||
'user-online-devices.snapshot',
|
||||
now()->addMinutes($ttlMinutes),
|
||||
['user' => $targetUser->id]
|
||||
),
|
||||
'refreshInterval' => max(5, (int) $this->getConfig('refresh_interval_seconds', 15)),
|
||||
return $user;
|
||||
})
|
||||
);
|
||||
|
||||
$pageUsers = $paginator->getCollection()->count();
|
||||
$usersWithOnlineIp = $paginator->getCollection()->filter(function (User $user) {
|
||||
return ($user->online_count_live ?? 0) > 0;
|
||||
})->count();
|
||||
$totalOnlineIps = $paginator->getCollection()->sum(function (User $user) {
|
||||
return (int) ($user->online_count_live ?? 0);
|
||||
});
|
||||
|
||||
$securePath = admin_setting('secure_path', admin_setting('frontend_admin_path', hash('crc32b', config('app.key'))));
|
||||
|
||||
return view('UserOnlineDevices::admin-index', [
|
||||
'users' => $paginator,
|
||||
'filters' => [
|
||||
'keyword' => $keyword,
|
||||
'per_page' => $pageSize,
|
||||
],
|
||||
'summary' => [
|
||||
'page_users' => $pageUsers,
|
||||
'users_with_online_ip' => $usersWithOnlineIp,
|
||||
'total_online_ips' => $totalOnlineIps,
|
||||
'current_page' => $paginator->currentPage(),
|
||||
],
|
||||
'adminHomeUrl' => url('/' . $securePath),
|
||||
]);
|
||||
}
|
||||
|
||||
public function snapshot(Request $request, int $user)
|
||||
private function formatDateTime(mixed $value): string
|
||||
{
|
||||
abort_unless($this->isPluginEnabled(), 404);
|
||||
|
||||
$targetUser = User::query()->findOrFail($user);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->buildPayload($targetUser),
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildPayload(User $user): array
|
||||
{
|
||||
$devices = $this->deviceStateService->getUsersDevices([$user->id]);
|
||||
$ips = collect($devices[$user->id] ?? [])
|
||||
->filter(fn ($ip) => is_string($ip) && $ip !== '')
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
return [
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'online_count' => $ips->count(),
|
||||
'last_online_at' => $user->last_online_at,
|
||||
],
|
||||
'online_devices' => $this->formatOnlineDevices($ips),
|
||||
'active_sessions' => $this->shouldShowActiveSessions()
|
||||
? $this->formatActiveSessions($user)
|
||||
: [],
|
||||
'meta' => [
|
||||
'generated_at' => time(),
|
||||
'refresh_interval_seconds' => max(5, (int) $this->getConfig('refresh_interval_seconds', 15)),
|
||||
'mask_ip' => (bool) $this->getConfig('mask_ip', false),
|
||||
'show_active_sessions' => $this->shouldShowActiveSessions(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function buildAdminUsersPayload(LengthAwarePaginator $users): array
|
||||
{
|
||||
$items = $users->getCollection();
|
||||
$devicesByUser = $this->deviceStateService->getUsersDevices($items->pluck('id')->all());
|
||||
|
||||
$users->setCollection($items->map(function (User $user) use ($devicesByUser) {
|
||||
$ips = collect($devicesByUser[$user->id] ?? [])
|
||||
->filter(fn ($ip) => is_string($ip) && $ip !== '')
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'online_count' => $ips->count(),
|
||||
'online_devices' => $this->formatOnlineDevices($ips),
|
||||
'last_online_at' => $user->last_online_at?->timestamp,
|
||||
'created_at' => $user->created_at?->timestamp,
|
||||
];
|
||||
}));
|
||||
|
||||
return [
|
||||
'list' => $users->items(),
|
||||
'pagination' => [
|
||||
'current' => $users->currentPage(),
|
||||
'pageSize' => $users->perPage(),
|
||||
'total' => $users->total(),
|
||||
'lastPage' => $users->lastPage(),
|
||||
],
|
||||
'meta' => [
|
||||
'generated_at' => time(),
|
||||
'refresh_interval_seconds' => max(5, (int) $this->getConfig('refresh_interval_seconds', 15)),
|
||||
'mask_ip' => (bool) $this->getConfig('mask_ip', false),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function formatOnlineDevices(Collection $ips): array
|
||||
{
|
||||
return $ips->values()->map(function (string $ip, int $index) {
|
||||
return [
|
||||
'name' => 'Device ' . ($index + 1),
|
||||
'ip' => $this->maskIp($ip),
|
||||
'raw_ip' => $ip,
|
||||
];
|
||||
})->all();
|
||||
}
|
||||
|
||||
private function formatActiveSessions(User $user): array
|
||||
{
|
||||
return $user->tokens()
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->orderByDesc('last_used_at')
|
||||
->get(['id', 'name', 'last_used_at', 'created_at', 'expires_at'])
|
||||
->map(function ($token) {
|
||||
return [
|
||||
'id' => $token->id,
|
||||
'device' => $token->name ?: 'Unknown Session',
|
||||
'last_used_at' => $token->last_used_at?->timestamp,
|
||||
'created_at' => $token->created_at?->timestamp,
|
||||
'expires_at' => $token->expires_at?->timestamp,
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
private function shouldShowActiveSessions(): bool
|
||||
{
|
||||
return (bool) $this->getConfig('show_active_sessions', true);
|
||||
}
|
||||
|
||||
private function maskIp(string $ip): string
|
||||
{
|
||||
if (!(bool) $this->getConfig('mask_ip', false)) {
|
||||
return $ip;
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return $value->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
$parts = explode('.', $ip);
|
||||
$parts[3] = '*';
|
||||
return implode('.', $parts);
|
||||
}
|
||||
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||
$parts = explode(':', $ip);
|
||||
$count = count($parts);
|
||||
if ($count > 2) {
|
||||
$parts[$count - 1] = '****';
|
||||
$parts[$count - 2] = '****';
|
||||
if (is_numeric($value)) {
|
||||
$timestamp = (int) $value;
|
||||
if ($timestamp > 0) {
|
||||
return date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
return implode(':', $parts);
|
||||
}
|
||||
|
||||
return $ip;
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$timestamp = strtotime($value);
|
||||
if ($timestamp !== false) {
|
||||
return date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,35 +2,12 @@
|
||||
|
||||
namespace Plugin\UserOnlineDevices;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Plugin\AbstractPlugin;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class Plugin extends AbstractPlugin
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
$this->filter('user.subscribe.response', function ($user) {
|
||||
if (!$user || empty($user['id'])) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$ttlMinutes = max(1, (int) $this->getConfig('signed_url_ttl_minutes', 60));
|
||||
$userId = (int) $user['id'];
|
||||
|
||||
$user['user_online_devices_enabled'] = true;
|
||||
$user['user_online_devices_panel_url'] = URL::temporarySignedRoute(
|
||||
'user-online-devices.panel',
|
||||
now()->addMinutes($ttlMinutes),
|
||||
['user' => $userId]
|
||||
);
|
||||
$user['user_online_devices_snapshot_url'] = URL::temporarySignedRoute(
|
||||
'user-online-devices.snapshot',
|
||||
now()->addMinutes($ttlMinutes),
|
||||
['user' => $userId]
|
||||
);
|
||||
|
||||
return $user;
|
||||
});
|
||||
// This plugin only exposes an admin page and does not need extra hooks.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
# User Online Devices Plugin
|
||||
# UserOnlineDevices
|
||||
|
||||
This plugin provides online IP monitoring for Xboard users and administrators.
|
||||
An admin-only Xboard plugin that shows all users' live online IP counts and active IP lists.
|
||||
|
||||
## Features
|
||||
|
||||
- Shows the current online device count based on Xboard's built-in `DeviceStateService`
|
||||
- Provides an administrator page to inspect all users' online IP counts
|
||||
- Provides an administrator API endpoint with paginated user online IP data
|
||||
- Provides a temporary signed dashboard page for a single user snapshot
|
||||
- Server-rendered admin page, no extra browser token parsing
|
||||
- Search by user ID or email
|
||||
- Page size switching
|
||||
- Live online IP list from `DeviceStateService`
|
||||
- Subscription name, last online time, and created time display
|
||||
|
||||
## Routes
|
||||
## Route
|
||||
|
||||
- `GET /api/v1/user-online-devices/summary`
|
||||
- `GET /api/v1/user-online-devices/panel-url`
|
||||
- `GET /api/v2/{secure_path}/user-online-devices/users`
|
||||
- `GET /{secure_path}/user-online-devices`
|
||||
- `GET /user-online-devices/panel/{user}` (temporary signed URL)
|
||||
After the plugin is installed and enabled, open:
|
||||
|
||||
`/{secure_path}/user-online-devices`
|
||||
|
||||
`secure_path` is your Xboard admin secure path.
|
||||
|
||||
## Notes
|
||||
|
||||
- This plugin reuses Xboard's existing real-time device state data from Redis.
|
||||
- In current Xboard releases, plugin development is primarily backend-oriented, so this plugin ships standalone pages instead of patching the compiled SPA bundle directly.
|
||||
- The "online device" count is effectively the number of unique online IPs reported by nodes.
|
||||
- The administrator entry is `/{secure_path}/user-online-devices`, which reads the current admin login token and shows paginated user online IP data.
|
||||
- The page is protected by Xboard's `admin` middleware
|
||||
- The plugin does not expose a public user page
|
||||
- Active IP data comes from node-reported device state in Redis
|
||||
|
||||
@@ -1,37 +1,19 @@
|
||||
{
|
||||
"name": "User Online Devices",
|
||||
"code": "user_online_devices",
|
||||
"version": "1.0.0",
|
||||
"description": "Show users their current online devices and online IP list with a signed dashboard page.",
|
||||
"version": "2.0.0",
|
||||
"description": "Admin page for viewing all users' online IP counts and active IP lists.",
|
||||
"author": "OpenAI Codex",
|
||||
"type": "feature",
|
||||
"require": {
|
||||
"xboard": ">=1.0.0"
|
||||
},
|
||||
"config": {
|
||||
"refresh_interval_seconds": {
|
||||
"default_page_size": {
|
||||
"type": "number",
|
||||
"default": 15,
|
||||
"label": "Refresh Interval",
|
||||
"description": "Dashboard auto-refresh interval in seconds."
|
||||
},
|
||||
"signed_url_ttl_minutes": {
|
||||
"type": "number",
|
||||
"default": 60,
|
||||
"label": "Signed URL TTL",
|
||||
"description": "Temporary dashboard link validity in minutes."
|
||||
},
|
||||
"mask_ip": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"label": "Mask IP Address",
|
||||
"description": "Whether to partially mask IP addresses on the dashboard."
|
||||
},
|
||||
"show_active_sessions": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"label": "Show Active Sessions",
|
||||
"description": "Whether to also show Sanctum login sessions."
|
||||
"default": 20,
|
||||
"label": "Default Page Size",
|
||||
"description": "Default number of users shown per page on the admin monitor."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
381
plugins/UserOnlineDevices/resources/views/admin-index.blade.php
Normal file
381
plugins/UserOnlineDevices/resources/views/admin-index.blade.php
Normal file
@@ -0,0 +1,381 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>User Online Devices</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #eff5f4;
|
||||
--panel: #ffffff;
|
||||
--line: #d9e3e1;
|
||||
--text: #172126;
|
||||
--muted: #5f6f74;
|
||||
--accent: #0f766e;
|
||||
--accent-soft: rgba(15, 118, 110, 0.10);
|
||||
--danger-soft: rgba(185, 28, 28, 0.08);
|
||||
--shadow: 0 18px 46px rgba(23, 33, 38, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--text);
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(15, 118, 110, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, #f6fbfa 0%, #edf3f1 100%);
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1360px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 20px 40px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 10px 0 12px;
|
||||
font-size: clamp(30px, 4vw, 50px);
|
||||
line-height: 1.06;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
max-width: 860px;
|
||||
color: var(--muted);
|
||||
line-height: 1.8;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid rgba(217, 227, 225, 0.94);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin-top: 10px;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.stat-note {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select,
|
||||
.btn {
|
||||
min-height: 46px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select {
|
||||
padding: 12px 14px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.input {
|
||||
min-width: 280px;
|
||||
flex: 1 1 320px;
|
||||
}
|
||||
|
||||
.select {
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
background: linear-gradient(135deg, #0f766e, #115e59);
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 14px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.count {
|
||||
display: inline-flex;
|
||||
min-width: 42px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.count.zero {
|
||||
background: #eef2f2;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.ips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f3f5f5;
|
||||
color: #374151;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 10px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
border: 1px dashed var(--line);
|
||||
color: var(--muted);
|
||||
background: #f9fbfb;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 4px 2px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pager .btn[aria-disabled="true"] {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<section class="hero">
|
||||
<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.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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-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-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-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>
|
||||
</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">
|
||||
@foreach ([20, 50, 100] as $size)
|
||||
<option value="{{ $size }}" @selected((int) $filters['per_page'] === $size)>{{ $size }} / page</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button class="btn primary" type="submit">Search</button>
|
||||
<a class="btn" href="{{ request()->url() }}">Reset</a>
|
||||
<a class="btn" href="{{ $adminHomeUrl }}">Back Admin</a>
|
||||
</form>
|
||||
|
||||
<section class="card panel">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Email</th>
|
||||
<th>Subscription</th>
|
||||
<th>Online IP Count</th>
|
||||
<th>Online IP List</th>
|
||||
<th>Last Online</th>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div>Total {{ $users->total() }} users, showing page {{ $users->currentPage() }} / {{ $users->lastPage() }}</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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,793 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Online IP Monitor</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f3f4f6;
|
||||
--panel: #ffffff;
|
||||
--line: #e5e7eb;
|
||||
--text: #111827;
|
||||
--muted: #6b7280;
|
||||
--accent: #0f766e;
|
||||
--accent-soft: rgba(15, 118, 110, 0.1);
|
||||
--shadow: 0 18px 45px rgba(17, 24, 39, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(15, 118, 110, 0.08), transparent 30%),
|
||||
linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 20px 40px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 8px 0 10px;
|
||||
font-size: clamp(30px, 4vw, 52px);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
max-width: 820px;
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select,
|
||||
.btn {
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select {
|
||||
padding: 12px 14px;
|
||||
min-height: 46px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.input {
|
||||
min-width: 260px;
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
|
||||
.select {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
min-height: 46px;
|
||||
padding: 0 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
background: linear-gradient(135deg, #0f766e, #115e59);
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid rgba(229, 231, 235, 0.92);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin-top: 10px;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.stat-note {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 14px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 42px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.error,
|
||||
.empty {
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-bottom: 14px;
|
||||
background: rgba(185, 28, 28, 0.08);
|
||||
border: 1px solid rgba(185, 28, 28, 0.14);
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.empty {
|
||||
background: #f9fafb;
|
||||
border: 1px dashed var(--line);
|
||||
color: var(--muted);
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 4px 2px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<section class="hero">
|
||||
<div class="eyebrow">Xboard Admin Monitor</div>
|
||||
<h1>All Users Online IP Count</h1>
|
||||
<div class="subtitle">
|
||||
This page is intended for administrators. It shows the current online IP quantity of all users reported by Xboard nodes, and supports keyword search by user ID or email.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="error-box" class="error" style="display:none;"></div>
|
||||
|
||||
<section class="summary">
|
||||
<article class="card stat">
|
||||
<div class="stat-label">Users On Current Page</div>
|
||||
<div class="stat-value" id="page-users">0</div>
|
||||
<div class="stat-note">How many user rows are currently loaded</div>
|
||||
</article>
|
||||
<article class="card stat">
|
||||
<div class="stat-label">Users With Online IP</div>
|
||||
<div class="stat-value" id="online-users">0</div>
|
||||
<div class="stat-note">Users on this page whose online 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" id="total-ips">0</div>
|
||||
<div class="stat-note" id="refresh-note">Waiting for data</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="toolbar">
|
||||
<input class="input" id="keyword-input" type="text" placeholder="Search by user ID or email">
|
||||
<select class="select" id="page-size">
|
||||
<option value="20">20 / page</option>
|
||||
<option value="50">50 / page</option>
|
||||
<option value="100">100 / page</option>
|
||||
</select>
|
||||
<button class="btn primary" id="search-btn" type="button">Search</button>
|
||||
<button class="btn secondary" id="reload-btn" type="button">Reload</button>
|
||||
<button class="btn secondary" id="home-btn" type="button">Back Admin</button>
|
||||
</section>
|
||||
|
||||
<section class="card panel">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Email</th>
|
||||
<th>Online IP Count</th>
|
||||
<th>Online IP List</th>
|
||||
<th>Last Online</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div id="pagination-text">Page 1</div>
|
||||
<div>
|
||||
<button class="btn secondary" id="prev-btn" type="button">Prev</button>
|
||||
<button class="btn secondary" id="next-btn" type="button">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const API_ENDPOINT = @json($apiEndpoint);
|
||||
const ADMIN_HOME_URL = @json($adminHomeUrl);
|
||||
const REFRESH_MS = 30000;
|
||||
const ADMIN_TOKEN_KEYS = [
|
||||
'XBOARD_ACCESS_TOKEN',
|
||||
'Xboard_access_token',
|
||||
'Xboard_ACCESS_TOKEN',
|
||||
'xboard_access_token'
|
||||
];
|
||||
const TOKEN_KEYS = [
|
||||
'XBOARD_ACCESS_TOKEN',
|
||||
'Xboard_access_token',
|
||||
'Xboard_ACCESS_TOKEN',
|
||||
'xboard_access_token',
|
||||
'auth_data',
|
||||
'AUTH_DATA',
|
||||
'admin_auth_data',
|
||||
'ADMIN_AUTH_DATA',
|
||||
'VUE_NAIVE_ACCESS_TOKEN',
|
||||
'Vue_Naive_access_token',
|
||||
'access_token',
|
||||
'AccessToken',
|
||||
'ACCESS_TOKEN'
|
||||
];
|
||||
const state = {
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
lastPage: 1,
|
||||
keyword: ''
|
||||
};
|
||||
|
||||
function isObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeToken(candidate) {
|
||||
if (typeof candidate !== 'string') return '';
|
||||
|
||||
const trimmed = candidate.trim().replace(/^"|"$/g, '');
|
||||
if (!trimmed) return '';
|
||||
|
||||
const raw = trimmed.replace(/^Bearer\s+/i, '').trim();
|
||||
if (raw.length < 20 || /\s/.test(raw)) return '';
|
||||
|
||||
return 'Bearer ' + raw;
|
||||
}
|
||||
|
||||
function normalizeExpiryTimestamp(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (numeric > 9999999999) {
|
||||
return Math.floor(numeric / 1000);
|
||||
}
|
||||
|
||||
return Math.floor(numeric);
|
||||
}
|
||||
|
||||
function addCandidateToken(bucket, candidate, priority) {
|
||||
const normalized = normalizeToken(candidate);
|
||||
if (!normalized) return;
|
||||
|
||||
const existing = bucket.get(normalized);
|
||||
if (!existing || priority > existing.priority) {
|
||||
bucket.set(normalized, {
|
||||
token: normalized,
|
||||
priority: priority
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function extractTokenFromParsedValue(input) {
|
||||
if (!input) return '';
|
||||
|
||||
if (typeof input === 'string') {
|
||||
return normalizeToken(input);
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
for (const item of input) {
|
||||
const token = extractTokenFromParsedValue(item);
|
||||
if (token) return token;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!isObject(input)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof input.expired === 'boolean' && input.expired) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const timeStoredAt = normalizeExpiryTimestamp(input.time || input.created_at || input.createdAt || input.stored_at || input.storedAt);
|
||||
const absoluteExpiresAt = normalizeExpiryTimestamp(input.expires_at || input.expiresAt || input.expire_at || input.expireAt);
|
||||
const relativeExpireSeconds = normalizeExpiryTimestamp(input.expire);
|
||||
|
||||
if (absoluteExpiresAt && absoluteExpiresAt < nowSeconds) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (timeStoredAt && relativeExpireSeconds && (timeStoredAt + relativeExpireSeconds) < nowSeconds) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const directKeys = ['value', 'token', 'access_token', 'accessToken', 'data'];
|
||||
for (const key of directKeys) {
|
||||
const token = extractTokenFromParsedValue(input[key]);
|
||||
if (token) return token;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function collectTokensFromUnknown(input, bucket, priority) {
|
||||
if (!input) return;
|
||||
|
||||
if (typeof input === 'string') {
|
||||
addCandidateToken(bucket, input, priority);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
input.forEach(function (item) {
|
||||
collectTokensFromUnknown(item, bucket, priority - 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isObject(input)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.is_admin === true || input.isAdmin === true) {
|
||||
addCandidateToken(bucket, input.auth_data || input.authData || input.authorization || input.token, priority + 20);
|
||||
}
|
||||
|
||||
const directKeys = ['auth_data', 'authData', 'authorization', 'token', 'access_token', 'accessToken', 'value', 'data'];
|
||||
directKeys.forEach(function (key) {
|
||||
if (Object.prototype.hasOwnProperty.call(input, key)) {
|
||||
collectTokensFromUnknown(input[key], bucket, priority - (key === 'data' ? 2 : 0));
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(input).forEach(function (key) {
|
||||
if (directKeys.indexOf(key) !== -1) return;
|
||||
collectTokensFromUnknown(input[key], bucket, priority - 3);
|
||||
});
|
||||
}
|
||||
|
||||
function parseStoredToken(rawValue) {
|
||||
if (!rawValue || typeof rawValue !== 'string') return '';
|
||||
|
||||
const trimmed = rawValue.trim();
|
||||
const direct = normalizeToken(trimmed);
|
||||
if (direct) return direct;
|
||||
|
||||
if (trimmed[0] === '{' || trimmed[0] === '[') {
|
||||
try {
|
||||
return extractTokenFromParsedValue(JSON.parse(trimmed));
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function collectStorageCandidates(storage, bucket) {
|
||||
if (!storage) return;
|
||||
|
||||
for (const key of TOKEN_KEYS) {
|
||||
addCandidateToken(bucket, storage.getItem(key), 100);
|
||||
}
|
||||
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (!key) continue;
|
||||
|
||||
const rawValue = storage.getItem(key);
|
||||
if (!rawValue) continue;
|
||||
|
||||
const normalizedKey = key.toLowerCase();
|
||||
const priority = normalizedKey.indexOf('admin') !== -1
|
||||
? 90
|
||||
: normalizedKey.indexOf('auth') !== -1
|
||||
? 80
|
||||
: normalizedKey.indexOf('token') !== -1
|
||||
? 70
|
||||
: 20;
|
||||
|
||||
addCandidateToken(bucket, rawValue, priority);
|
||||
|
||||
const trimmed = rawValue.trim();
|
||||
if (trimmed[0] === '{' || trimmed[0] === '[') {
|
||||
try {
|
||||
collectTokensFromUnknown(JSON.parse(trimmed), bucket, priority);
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAdminAuthorizationFromXboardStorage() {
|
||||
const storages = [window.localStorage, window.sessionStorage];
|
||||
|
||||
for (const storage of storages) {
|
||||
if (!storage) continue;
|
||||
|
||||
for (const key of ADMIN_TOKEN_KEYS) {
|
||||
const token = parseStoredToken(storage.getItem(key));
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildAuthorizationCandidates() {
|
||||
const bucket = new Map();
|
||||
collectStorageCandidates(window.localStorage, bucket);
|
||||
collectStorageCandidates(window.sessionStorage, bucket);
|
||||
|
||||
return Array.from(bucket.values())
|
||||
.sort(function (left, right) {
|
||||
return right.priority - left.priority;
|
||||
})
|
||||
.map(function (item) {
|
||||
return item.token;
|
||||
});
|
||||
}
|
||||
|
||||
async function pickAuthorization() {
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
const adminAuthorization = getAdminAuthorizationFromXboardStorage();
|
||||
|
||||
if (adminAuthorization) {
|
||||
candidates.push(adminAuthorization);
|
||||
seen.add(adminAuthorization);
|
||||
}
|
||||
|
||||
buildAuthorizationCandidates().forEach(function (authorization) {
|
||||
if (!seen.has(authorization)) {
|
||||
seen.add(authorization);
|
||||
candidates.push(authorization);
|
||||
}
|
||||
});
|
||||
|
||||
for (const authorization of candidates) {
|
||||
try {
|
||||
const url = new URL(API_ENDPOINT, window.location.origin);
|
||||
url.searchParams.set('current', '1');
|
||||
url.searchParams.set('pageSize', '1');
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
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 '-';
|
||||
|
||||
let date = null;
|
||||
if (typeof timestamp === 'number') {
|
||||
date = new Date(timestamp > 9999999999 ? timestamp : timestamp * 1000);
|
||||
} else if (typeof timestamp === 'string') {
|
||||
const trimmed = timestamp.trim();
|
||||
if (!trimmed) return '-';
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const numeric = Number(trimmed);
|
||||
date = new Date(numeric > 9999999999 ? numeric : numeric * 1000);
|
||||
} else {
|
||||
date = new Date(trimmed);
|
||||
}
|
||||
} else {
|
||||
date = new Date(timestamp);
|
||||
}
|
||||
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
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 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 resetTable() {
|
||||
document.getElementById('table-body').innerHTML = '<tr><td colspan="6"><div class="empty">No data available.</div></td></tr>';
|
||||
document.getElementById('page-users').textContent = '0';
|
||||
document.getElementById('online-users').textContent = '0';
|
||||
document.getElementById('total-ips').textContent = '0';
|
||||
document.getElementById('refresh-note').textContent = 'Waiting for data';
|
||||
document.getElementById('pagination-text').textContent = 'Page 1';
|
||||
}
|
||||
|
||||
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 renderData(payload) {
|
||||
const list = Array.isArray(payload.list) ? payload.list : [];
|
||||
const onlineUsers = list.filter(function (item) { return Number(item.online_count || 0) > 0; }).length;
|
||||
const totalIps = list.reduce(function (sum, item) {
|
||||
return sum + Number(item.online_count || 0);
|
||||
}, 0);
|
||||
|
||||
document.getElementById('page-users').textContent = String(list.length);
|
||||
document.getElementById('online-users').textContent = String(onlineUsers);
|
||||
document.getElementById('total-ips').textContent = String(totalIps);
|
||||
document.getElementById('refresh-note').textContent = 'Refresh every ' + ((payload.meta && payload.meta.refresh_interval_seconds) || 30) + 's';
|
||||
|
||||
const pagination = payload.pagination || {};
|
||||
state.total = Number(pagination.total || 0);
|
||||
state.lastPage = Number(pagination.lastPage || 1);
|
||||
document.getElementById('pagination-text').textContent = 'Page ' + state.current + ' / ' + state.lastPage + ' , Total ' + state.total;
|
||||
|
||||
const body = document.getElementById('table-body');
|
||||
body.innerHTML = '';
|
||||
|
||||
if (!list.length) {
|
||||
body.innerHTML = '<tr><td colspan="6"><div class="empty">No users found for the current filter.</div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.forEach(function (item) {
|
||||
const row = document.createElement('tr');
|
||||
const devices = Array.isArray(item.online_devices) ? item.online_devices : [];
|
||||
const ipHtml = devices.length
|
||||
? '<div class="ips">' + devices.map(function (device) {
|
||||
return '<span class="ip">' + device.ip + '</span>';
|
||||
}).join('') + '</div>'
|
||||
: '<span class="muted">No online IP</span>';
|
||||
|
||||
row.innerHTML =
|
||||
'<td>' + item.id + '</td>' +
|
||||
'<td>' + item.email + '</td>' +
|
||||
'<td><span class="count">' + item.online_count + '</span></td>' +
|
||||
'<td>' + ipHtml + '</td>' +
|
||||
'<td>' + formatTime(item.last_online_at) + '</td>' +
|
||||
'<td>' + formatTime(item.created_at) + '</td>';
|
||||
body.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const authorization = await pickAuthorization();
|
||||
if (!authorization) {
|
||||
resetTable();
|
||||
setError('No usable admin login token was found in browser storage. Please log in to the Xboard admin panel first, then refresh this page.');
|
||||
return;
|
||||
}
|
||||
|
||||
clearError();
|
||||
|
||||
const url = new URL(API_ENDPOINT, window.location.origin);
|
||||
url.searchParams.set('current', String(state.current));
|
||||
url.searchParams.set('pageSize', String(state.pageSize));
|
||||
if (state.keyword) {
|
||||
url.searchParams.set('keyword', state.keyword);
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await getJson(url.toString(), authorization);
|
||||
renderData(payload);
|
||||
} catch (error) {
|
||||
resetTable();
|
||||
setError('Failed to load admin user online IP data. Please confirm you are logged in as an administrator and the plugin is enabled.');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('search-btn').addEventListener('click', function () {
|
||||
state.current = 1;
|
||||
state.keyword = document.getElementById('keyword-input').value.trim();
|
||||
state.pageSize = Number(document.getElementById('page-size').value || 20);
|
||||
loadData();
|
||||
});
|
||||
|
||||
document.getElementById('reload-btn').addEventListener('click', function () {
|
||||
state.pageSize = Number(document.getElementById('page-size').value || 20);
|
||||
loadData();
|
||||
});
|
||||
|
||||
document.getElementById('home-btn').addEventListener('click', function () {
|
||||
window.location.href = ADMIN_HOME_URL;
|
||||
});
|
||||
|
||||
document.getElementById('prev-btn').addEventListener('click', function () {
|
||||
if (state.current <= 1) return;
|
||||
state.current -= 1;
|
||||
loadData();
|
||||
});
|
||||
|
||||
document.getElementById('next-btn').addEventListener('click', function () {
|
||||
if (state.current >= state.lastPage) return;
|
||||
state.current += 1;
|
||||
loadData();
|
||||
});
|
||||
|
||||
document.getElementById('keyword-input').addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Enter') {
|
||||
document.getElementById('search-btn').click();
|
||||
}
|
||||
});
|
||||
|
||||
resetTable();
|
||||
loadData();
|
||||
window.setInterval(loadData, REFRESH_MS);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,352 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>在线设备与在线 IP</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f3f0e8;
|
||||
--card: rgba(255, 252, 246, 0.94);
|
||||
--line: #d8ccb6;
|
||||
--text: #2f261a;
|
||||
--muted: #786b58;
|
||||
--accent: #1f7a5a;
|
||||
--accent-soft: #d9efe5;
|
||||
--warning: #8f4c2d;
|
||||
--shadow: 0 18px 50px rgba(77, 56, 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.75), transparent 34%),
|
||||
linear-gradient(135deg, #efe4d0 0%, #f9f5ee 45%, #e6dccb 100%);
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 48px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(30px, 5vw, 54px);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
max-width: 720px;
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid rgba(216, 204, 182, 0.8);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin-top: 12px;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-note {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-list,
|
||||
.session-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
border: 1px solid rgba(216, 204, 182, 0.7);
|
||||
}
|
||||
|
||||
.row-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.row-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.row-meta {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 28px 20px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 18px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.48);
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
margin-top: 18px;
|
||||
color: var(--warning);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<section class="hero">
|
||||
<div class="eyebrow">Xboard Plugin Dashboard</div>
|
||||
<h1>在线设备与在线 IP</h1>
|
||||
<div class="subtitle">
|
||||
当前页面由 User Online Devices 插件生成,会自动刷新并显示该账号最近上报到 Xboard 的在线 IP 列表。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="summary">
|
||||
<article class="card stat">
|
||||
<div class="stat-label">账号</div>
|
||||
<div class="stat-value" id="user-email">{{ $payload['user']['email'] }}</div>
|
||||
<div class="stat-note">用户 ID: <span id="user-id">{{ $payload['user']['id'] }}</span></div>
|
||||
</article>
|
||||
<article class="card stat">
|
||||
<div class="stat-label">在线设备数</div>
|
||||
<div class="stat-value" id="online-count">{{ $payload['user']['online_count'] }}</div>
|
||||
<div class="stat-note">按实时在线 IP 去重统计</div>
|
||||
</article>
|
||||
<article class="card stat">
|
||||
<div class="stat-label">最后活跃</div>
|
||||
<div class="stat-value" id="last-online">{{ $payload['user']['last_online_at'] ? date('Y-m-d H:i:s', $payload['user']['last_online_at']) : '暂无记录' }}</div>
|
||||
<div class="stat-note">数据来源于 Xboard 设备状态服务</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="card panel">
|
||||
<div class="panel-head">
|
||||
<h2>在线 IP 列表</h2>
|
||||
<div class="badge">每 {{ $refreshInterval }} 秒刷新</div>
|
||||
</div>
|
||||
<div class="device-list" id="device-list"></div>
|
||||
<div class="footer-note">
|
||||
说明:这里展示的是节点最近上报的在线 IP,Xboard 本体按 IP 去重,所以“在线设备”本质上是“当前唯一在线 IP 数”。
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card panel">
|
||||
<div class="panel-head">
|
||||
<h2>登录会话</h2>
|
||||
<div class="badge" id="session-badge">{{ $payload['meta']['show_active_sessions'] ? '已开启' : '已关闭' }}</div>
|
||||
</div>
|
||||
<div class="session-list" id="session-list"></div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const snapshotUrl = @json($snapshotUrl);
|
||||
const refreshInterval = {{ (int) $refreshInterval }};
|
||||
const initialPayload = @json($payload);
|
||||
|
||||
function formatTime(timestamp) {
|
||||
if (!timestamp) return '暂无记录';
|
||||
const date = new Date(timestamp * 1000);
|
||||
const pad = (value) => 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 createEmpty(message) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'empty';
|
||||
div.textContent = message;
|
||||
return div;
|
||||
}
|
||||
|
||||
function renderDevices(devices) {
|
||||
const root = document.getElementById('device-list');
|
||||
root.innerHTML = '';
|
||||
|
||||
if (!devices.length) {
|
||||
root.appendChild(createEmpty('当前没有检测到在线设备。'));
|
||||
return;
|
||||
}
|
||||
|
||||
devices.forEach((device) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
row.innerHTML = `
|
||||
<div class="row-top">
|
||||
<div class="row-title">${device.name}</div>
|
||||
<div class="badge">在线</div>
|
||||
</div>
|
||||
<div class="row-meta">IP: ${device.ip}</div>
|
||||
`;
|
||||
root.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSessions(sessions, enabled) {
|
||||
const root = document.getElementById('session-list');
|
||||
root.innerHTML = '';
|
||||
|
||||
if (!enabled) {
|
||||
root.appendChild(createEmpty('管理员已关闭登录会话展示。'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessions.length) {
|
||||
root.appendChild(createEmpty('当前没有可展示的登录会话。'));
|
||||
return;
|
||||
}
|
||||
|
||||
sessions.forEach((session) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
row.innerHTML = `
|
||||
<div class="row-top">
|
||||
<div class="row-title">${session.device}</div>
|
||||
<div class="badge">#${session.id}</div>
|
||||
</div>
|
||||
<div class="row-meta">
|
||||
最近使用: ${formatTime(session.last_used_at)}<br>
|
||||
创建时间: ${formatTime(session.created_at)}<br>
|
||||
过期时间: ${formatTime(session.expires_at)}
|
||||
</div>
|
||||
`;
|
||||
root.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function render(payload) {
|
||||
document.getElementById('user-email').textContent = payload.user.email;
|
||||
document.getElementById('user-id').textContent = payload.user.id;
|
||||
document.getElementById('online-count').textContent = payload.user.online_count;
|
||||
document.getElementById('last-online').textContent = formatTime(payload.user.last_online_at);
|
||||
document.getElementById('session-badge').textContent = payload.meta.show_active_sessions ? '已开启' : '已关闭';
|
||||
renderDevices(payload.online_devices || []);
|
||||
renderSessions(payload.active_sessions || [], payload.meta.show_active_sessions);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const response = await fetch(snapshotUrl, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const result = await response.json();
|
||||
if (result && result.data) {
|
||||
render(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh online devices snapshot.', error);
|
||||
}
|
||||
}
|
||||
|
||||
render(initialPayload);
|
||||
window.setInterval(refresh, refreshInterval * 1000);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,550 +0,0 @@
|
||||
<!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(2, minmax(0, 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: 1fr;
|
||||
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) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
</section>
|
||||
|
||||
<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>
|
||||
</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;
|
||||
const TOKEN_KEYS = [
|
||||
'VUE_NAIVE_ACCESS_TOKEN',
|
||||
'Vue_Naive_access_token',
|
||||
'access_token',
|
||||
'AccessToken',
|
||||
'ACCESS_TOKEN'
|
||||
];
|
||||
let lastAuthorization = '';
|
||||
|
||||
function isObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeToken(candidate) {
|
||||
if (typeof candidate !== 'string') return '';
|
||||
|
||||
const trimmed = candidate.trim().replace(/^"|"$/g, '');
|
||||
if (!trimmed) return '';
|
||||
|
||||
const raw = trimmed.replace(/^Bearer\s+/i, '').trim();
|
||||
if (raw.length < 20 || /\s/.test(raw)) return '';
|
||||
|
||||
return 'Bearer ' + raw;
|
||||
}
|
||||
|
||||
function extractTokenFromParsedValue(input) {
|
||||
if (!input) return '';
|
||||
|
||||
if (typeof input === 'string') {
|
||||
return normalizeToken(input);
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
for (const item of input) {
|
||||
const token = extractTokenFromParsedValue(item);
|
||||
if (token) return token;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!isObject(input)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof input.expired === 'boolean' && input.expired) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const expiresAt = Number(input.expires_at || input.expiresAt || input.expire_at || input.expireAt || 0);
|
||||
if (expiresAt && expiresAt < Math.floor(Date.now() / 1000)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const directKeys = ['value', 'token', 'access_token', 'accessToken', 'data'];
|
||||
for (const key of directKeys) {
|
||||
const token = extractTokenFromParsedValue(input[key]);
|
||||
if (token) return token;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseStoredToken(rawValue) {
|
||||
if (!rawValue || typeof rawValue !== 'string') return '';
|
||||
|
||||
const trimmed = rawValue.trim();
|
||||
const direct = normalizeToken(trimmed);
|
||||
if (direct) return direct;
|
||||
|
||||
if (trimmed[0] === '{' || trimmed[0] === '[') {
|
||||
try {
|
||||
return extractTokenFromParsedValue(JSON.parse(trimmed));
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function readTokenFromStorage(storage) {
|
||||
if (!storage) return '';
|
||||
|
||||
for (const key of TOKEN_KEYS) {
|
||||
const token = parseStoredToken(storage.getItem(key));
|
||||
if (token) return token;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getAuthorization() {
|
||||
return readTokenFromStorage(window.localStorage) || readTokenFromStorage(window.sessionStorage);
|
||||
}
|
||||
|
||||
function formatTime(timestamp) {
|
||||
if (!timestamp) return '-';
|
||||
let date = null;
|
||||
|
||||
if (typeof timestamp === 'number') {
|
||||
date = new Date(timestamp > 9999999999 ? timestamp : timestamp * 1000);
|
||||
} else if (typeof timestamp === 'string') {
|
||||
const trimmed = timestamp.trim();
|
||||
if (!trimmed) return '-';
|
||||
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const numeric = Number(trimmed);
|
||||
date = new Date(numeric > 9999999999 ? numeric : numeric * 1000);
|
||||
} else {
|
||||
date = new Date(trimmed);
|
||||
}
|
||||
} else {
|
||||
date = new Date(timestamp);
|
||||
}
|
||||
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
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 = '';
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
document.getElementById('user-email').textContent = '-';
|
||||
document.getElementById('user-id').textContent = 'Please log in first';
|
||||
document.getElementById('online-count').textContent = '-';
|
||||
document.getElementById('last-online').textContent = '-';
|
||||
document.getElementById('plan-name').textContent = '-';
|
||||
document.getElementById('traffic-note').textContent = '-';
|
||||
document.getElementById('refresh-badge').textContent = 'Auto Refresh';
|
||||
document.getElementById('ip-list').innerHTML = '<div class="empty">No online IP detected currently.</div>';
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPageData() {
|
||||
const authorization = getAuthorization();
|
||||
if (authorization !== lastAuthorization) {
|
||||
resetView();
|
||||
lastAuthorization = authorization;
|
||||
}
|
||||
|
||||
if (!authorization) {
|
||||
setError('No usable login token was found in Xboard 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) {
|
||||
resetView();
|
||||
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);
|
||||
window.addEventListener('storage', function (event) {
|
||||
if (!event.key || TOKEN_KEYS.indexOf(event.key) !== -1) {
|
||||
loadPageData();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,22 +1,3 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Plugin\UserOnlineDevices\Controllers\UserOnlineDevicesController;
|
||||
|
||||
$securePath = admin_setting('secure_path', admin_setting('frontend_admin_path', hash('crc32b', config('app.key'))));
|
||||
|
||||
Route::group([
|
||||
'prefix' => 'api/v1/user-online-devices',
|
||||
'middleware' => 'user',
|
||||
], function () {
|
||||
Route::get('/summary', [UserOnlineDevicesController::class, 'summary']);
|
||||
Route::get('/panel-url', [UserOnlineDevicesController::class, 'panelUrl']);
|
||||
});
|
||||
|
||||
Route::group([
|
||||
'prefix' => 'api/v2/' . $securePath . '/user-online-devices',
|
||||
'middleware' => 'admin',
|
||||
], function () {
|
||||
Route::get('/users', [UserOnlineDevicesController::class, 'adminUsers'])
|
||||
->name('user-online-devices.admin-users-data');
|
||||
});
|
||||
// This plugin intentionally uses a server-rendered admin page only.
|
||||
|
||||
@@ -5,15 +5,9 @@ use Plugin\UserOnlineDevices\Controllers\UserOnlineDevicesController;
|
||||
|
||||
$securePath = admin_setting('secure_path', admin_setting('frontend_admin_path', hash('crc32b', config('app.key'))));
|
||||
|
||||
Route::get('/' . $securePath . '/user-online-devices', [UserOnlineDevicesController::class, 'adminUsersPage'])
|
||||
->name('user-online-devices.admin-users');
|
||||
|
||||
Route::get('/user-online-devices/panel/{user}', [UserOnlineDevicesController::class, 'panel'])
|
||||
->whereNumber('user')
|
||||
->middleware('signed')
|
||||
->name('user-online-devices.panel');
|
||||
|
||||
Route::get('/user-online-devices/snapshot/{user}', [UserOnlineDevicesController::class, 'snapshot'])
|
||||
->whereNumber('user')
|
||||
->middleware('signed')
|
||||
->name('user-online-devices.snapshot');
|
||||
Route::group([
|
||||
'prefix' => $securePath,
|
||||
'middleware' => ['admin'],
|
||||
], function () {
|
||||
Route::get('/user-online-devices', [UserOnlineDevicesController::class, 'index']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user