first commit

This commit is contained in:
CN-JS-HuiBai
2026-04-07 16:54:24 +08:00
commit 2c6a38c80d
399 changed files with 42205 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
<?php
namespace Plugin\UserOnlineDevices\Controllers;
use App\Http\Controllers\PluginController;
use App\Models\User;
use App\Services\DeviceStateService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\URL;
class UserOnlineDevicesController extends PluginController
{
public function __construct(
private readonly DeviceStateService $deviceStateService
) {
}
public function summary(Request $request)
{
if ($error = $this->beforePluginAction()) {
return $this->fail($error);
}
return $this->success($this->buildPayload($request->user()));
}
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 panel(Request $request, int $user): View
{
abort_unless($this->isPluginEnabled(), 404);
$targetUser = User::query()->findOrFail($user);
$payload = $this->buildPayload($targetUser);
$ttlMinutes = max(1, (int) $this->getConfig('signed_url_ttl_minutes', 60));
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)),
]);
}
public function snapshot(Request $request, int $user)
{
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 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()
->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 (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] = '****';
}
return implode(':', $parts);
}
return $ip;
}
}

View File

@@ -0,0 +1,36 @@
<?php
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;
});
}
}

View File

@@ -0,0 +1,23 @@
# User Online Devices Plugin
This plugin adds a user-facing online device dashboard for Xboard.
## Features
- Shows the current online device count based on Xboard's built-in `DeviceStateService`
- Shows the current online IP list for the logged-in user
- Optionally shows Sanctum login sessions
- Provides an authenticated API endpoint for the frontend to fetch the dashboard URL
- Provides a temporary signed dashboard page that auto-refreshes
## Routes
- `GET /api/v1/user-online-devices/summary`
- `GET /api/v1/user-online-devices/panel-url`
- `GET /user-online-devices/panel/{user}` (temporary signed URL)
## 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 a standalone user-side page instead of patching the compiled SPA bundle directly.
- The "online device" count is effectively the number of unique online IPs reported by nodes.

View File

@@ -0,0 +1,37 @@
{
"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.",
"author": "OpenAI Codex",
"type": "feature",
"require": {
"xboard": ">=1.0.0"
},
"config": {
"refresh_interval_seconds": {
"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."
}
}
}

View File

@@ -0,0 +1,352 @@
<!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">
说明:这里展示的是节点最近上报的在线 IPXboard 本体按 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>

View File

@@ -0,0 +1,12 @@
<?php
use Illuminate\Support\Facades\Route;
use Plugin\UserOnlineDevices\Controllers\UserOnlineDevicesController;
Route::group([
'prefix' => 'api/v1/user-online-devices',
'middleware' => 'user',
], function () {
Route::get('/summary', [UserOnlineDevicesController::class, 'summary']);
Route::get('/panel-url', [UserOnlineDevicesController::class, 'panelUrl']);
});

View File

@@ -0,0 +1,18 @@
<?php
use Illuminate\Support\Facades\Route;
use Plugin\UserOnlineDevices\Controllers\UserOnlineDevicesController;
Route::group([
'prefix' => 'user-online-devices',
], function () {
Route::get('/panel/{user}', [UserOnlineDevicesController::class, 'panel'])
->whereNumber('user')
->middleware('signed')
->name('user-online-devices.panel');
Route::get('/snapshot/{user}', [UserOnlineDevicesController::class, 'snapshot'])
->whereNumber('user')
->middleware('signed')
->name('user-online-devices.snapshot');
});