first commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
36
Xboard/plugins/UserOnlineDevices/Plugin.php
Normal file
36
Xboard/plugins/UserOnlineDevices/Plugin.php
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
23
Xboard/plugins/UserOnlineDevices/README.md
Normal file
23
Xboard/plugins/UserOnlineDevices/README.md
Normal 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.
|
||||
37
Xboard/plugins/UserOnlineDevices/config.json
Normal file
37
Xboard/plugins/UserOnlineDevices/config.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
352
Xboard/plugins/UserOnlineDevices/resources/views/panel.blade.php
Normal file
352
Xboard/plugins/UserOnlineDevices/resources/views/panel.blade.php
Normal 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">
|
||||
说明:这里展示的是节点最近上报的在线 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>
|
||||
12
Xboard/plugins/UserOnlineDevices/routes/api.php
Normal file
12
Xboard/plugins/UserOnlineDevices/routes/api.php
Normal 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']);
|
||||
});
|
||||
18
Xboard/plugins/UserOnlineDevices/routes/web.php
Normal file
18
Xboard/plugins/UserOnlineDevices/routes/web.php
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user