< Summary

Information
Class: AspNetCoreGeneratedDocument.Views_Shared__OnlineUsersMenu
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Views/Shared/_OnlineUsersMenu.cshtml
Line coverage
0%
Covered lines: 0
Uncovered lines: 1
Coverable lines: 1
Total lines: 290
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 4
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
<ExecuteAsync()0%2040%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Views/Shared/_OnlineUsersMenu.cshtml

#LineLine coverage
 1@*
 2    Секция «Пользователи онлайн» внутри выпадающего меню профиля в шапке.
 3    Данные берутся из NotificationHub: список подключённых пользователей, роли,
 4    длительность сеанса и текущая страница (детали сеанса хаб отдаёт только админам).
 5*@
 6<style>
 7    .ou-menu {
 8        min-width: 320px;
 9        max-width: 360px;
 10    }
 11
 12    .ou-head {
 13        display: flex;
 14        align-items: center;
 15        justify-content: space-between;
 16        gap: 0.5rem;
 17        padding: 0.4rem 1rem 0.35rem;
 18        font-size: 0.72rem;
 19        font-weight: 700;
 20        text-transform: uppercase;
 21        letter-spacing: 0.04em;
 22        color: #6b7280;
 23    }
 24
 25    .ou-head .badge {
 26        font-size: 0.65rem;
 27        font-weight: 700;
 28    }
 29
 30    .ou-list {
 31        max-height: 260px;
 32        overflow-y: auto;
 33        padding: 0 0.5rem 0.35rem;
 34    }
 35
 36    .ou-item {
 37        display: flex;
 38        align-items: flex-start;
 39        gap: 0.5rem;
 40        padding: 0.35rem 0.5rem;
 41        border-radius: 0.35rem;
 42    }
 43
 44    .ou-item:hover {
 45        background: #f3f4f6;
 46    }
 47
 48    .ou-item-icon {
 49        margin-top: 0.15rem;
 50        font-size: 0.8rem;
 51        flex-shrink: 0;
 52    }
 53
 54    .ou-item-body {
 55        min-width: 0;
 56        flex: 1;
 57    }
 58
 59    .ou-item-name {
 60        display: flex;
 61        align-items: center;
 62        gap: 0.35rem;
 63        font-size: 0.82rem;
 64        font-weight: 500;
 65        color: #111827;
 66    }
 67
 68    .ou-item-name span:first-child {
 69        overflow: hidden;
 70        text-overflow: ellipsis;
 71        white-space: nowrap;
 72    }
 73
 74    .ou-item-self {
 75        font-size: 0.62rem;
 76        font-weight: 700;
 77        color: #2563eb;
 78        flex-shrink: 0;
 79    }
 80
 81    .ou-item-meta {
 82        display: flex;
 83        flex-wrap: wrap;
 84        gap: 0.15rem 0.6rem;
 85        margin-top: 0.1rem;
 86        font-size: 0.7rem;
 87        color: #6b7280;
 88    }
 89
 90    .ou-item-meta a {
 91        color: #2563eb;
 92        text-decoration: none;
 93        overflow: hidden;
 94        text-overflow: ellipsis;
 95        white-space: nowrap;
 96        max-width: 190px;
 97    }
 98
 99    .ou-item-meta a:hover {
 100        text-decoration: underline;
 101    }
 102
 103    .ou-empty {
 104        padding: 0.75rem 1rem;
 105        font-size: 0.75rem;
 106        color: #9ca3af;
 107        text-align: center;
 108    }
 109</style>
 110
 111<li class="ou-head">
 112    <span><i class="fas fa-users me-1"></i>Сейчас онлайн</span>
 113    <span class="badge bg-success" id="ouCount">—</span>
 114</li>
 115<li>
 116    <div class="ou-list" id="ouList">
 117        <div class="ou-empty"><i class="fas fa-spinner fa-spin me-1"></i>Загрузка…</div>
 118    </div>
 119</li>
 120<li><hr class="dropdown-divider"></li>
 121
 122<script>
 123    (function () {
 0124        var CURRENT_USER = @Html.Raw(Json.Serialize(User.Identity?.Name ?? ""));
 125        var ownConnection = null;
 126        var refreshTimer = null;
 127
 128        function escapeHtml(text) {
 129            var div = document.createElement('div');
 130            div.textContent = text == null ? '' : String(text);
 131            return div.innerHTML;
 132        }
 133
 134        function activeConnection() {
 135            if (window.ChatManager && window.ChatManager.connection) return window.ChatManager.connection;
 136            return ownConnection;
 137        }
 138
 139        function isConnected(connection) {
 140            return !!connection && window.signalR && connection.state === signalR.HubConnectionState.Connected;
 141        }
 142
 143        function setPlaceholder(text, iconClass) {
 144            var list = document.getElementById('ouList');
 145            if (list) {
 146                list.innerHTML = '<div class="ou-empty"><i class="fas ' + iconClass + ' me-1"></i>' + escapeHtml(text) +
 147            }
 148        }
 149
 150        function render(usersList) {
 151            var list = document.getElementById('ouList');
 152            var counter = document.getElementById('ouCount');
 153            if (!list) return;
 154
 155            var users = (usersList || [])
 156                .filter(function (u) { return u && u.name && u.name !== 'Anonymous'; })
 157                .filter(function (u, i, all) { return i === all.findIndex(function (x) { return x.name === u.name; }); }
 158                .sort(function (a, b) { return a.name.localeCompare(b.name, 'ru'); });
 159
 160            if (counter) counter.textContent = users.length;
 161
 162            if (users.length === 0) {
 163                setPlaceholder('Нет активных пользователей', 'fa-user-slash');
 164                return;
 165            }
 166
 167            list.innerHTML = users.map(function (user) {
 168                var isAdmin = user.role === 'Admin';
 169                var isSelf = user.name === CURRENT_USER;
 170                var meta = '';
 171
 172                // Длительность сеанса и текущую страницу хаб отдаёт только админам.
 173                if (user.sessionDuration) {
 174                    meta += '<span><i class="fas fa-clock me-1"></i>' + escapeHtml(user.sessionDuration) + '</span>';
 175                }
 176                if (user.currentPage && user.currentPage !== 'Неизвестно') {
 177                    var page = user.currentPage.charAt(0) === '/' ? user.currentPage : '/' + user.currentPage;
 178                    meta += '<a href="' + escapeHtml(page) + '" title="' + escapeHtml(page) + '">'
 179                        + '<i class="fas fa-location-arrow me-1"></i>' + escapeHtml(user.currentPage) + '</a>';
 180                }
 181
 182                return '<div class="ou-item">'
 183                    + '<i class="ou-item-icon fas ' + (isAdmin ? 'fa-user-shield' : 'fa-user')
 184                    + '" style="color:' + (isAdmin ? '#dc3545' : '#0d6efd') + ';"></i>'
 185                    + '<div class="ou-item-body">'
 186                    + '<div class="ou-item-name"><span>' + escapeHtml(user.name) + '</span>'
 187                    + (isSelf ? '<span class="ou-item-self">вы</span>' : '') + '</div>'
 188                    + (meta ? '<div class="ou-item-meta">' + meta + '</div>' : '')
 189                    + '</div></div>';
 190            }).join('');
 191        }
 192
 193        async function refresh() {
 194            var connection = activeConnection();
 195            if (!isConnected(connection)) {
 196                connection = await ensureConnection();
 197            }
 198            if (!isConnected(connection)) {
 199                setPlaceholder('Нет соединения с сервером', 'fa-plug');
 200                return;
 201            }
 202            try {
 203                var users = await connection.invoke('GetConnectedUsersWithRoles');
 204                render(Array.from(users || []));
 205            } catch (err) {
 206                console.error('Не удалось получить список пользователей онлайн:', err);
 207                setPlaceholder('Ошибка загрузки', 'fa-triangle-exclamation');
 208            }
 209        }
 210
 211        function subscribe(connection) {
 212            if (!connection || connection.__ouSubscribed) return;
 213            connection.__ouSubscribed = true;
 214            connection.on('UserConnected', function (_count, usersList) { render(usersList || []); });
 215            connection.on('UserDisconnected', function (_count, usersList) { render(usersList || []); });
 216            connection.on('UsersListUpdated', function (usersList) { render(usersList || []); });
 217        }
 218
 219        // Основное соединение поднимает ChatManager в layout; если его нет — открываем своё.
 220        async function ensureConnection() {
 221            var connection = activeConnection();
 222            if (isConnected(connection)) {
 223                subscribe(connection);
 224                return connection;
 225            }
 226            if (!window.signalR) return null;
 227
 228            // ChatManager стартует параллельно с этим меню. Дожидаемся его соединения, чтобы
 229            // не держать на вкладке второе подключение к тому же хабу: каждое лишнее
 230            // соединение — ещё один negotiate и ещё один обрыв при перезапуске контейнера.
 231            if (window.Studio2SignalR && !ownConnection) {
 232                var shared = await window.Studio2SignalR.waitForSharedConnection();
 233                if (isConnected(shared)) {
 234                    subscribe(shared);
 235                    return shared;
 236                }
 237            }
 238
 239            if (!ownConnection) {
 240                ownConnection = new signalR.HubConnectionBuilder()
 241                    .withUrl('/notificationHub')
 242                    .configureLogging(window.Studio2SignalR ? window.Studio2SignalR.createLogger('online-users') : signa
 243                    .withAutomaticReconnect()
 244                    .build();
 245            }
 246            if (ownConnection.state === signalR.HubConnectionState.Disconnected) {
 247                try {
 248                    await ownConnection.start();
 249                } catch (err) {
 250                    console.debug('Не удалось подключиться к notificationHub (возможно обновление сервера):', err?.messa
 251                    return null;
 252                }
 253            }
 254            subscribe(ownConnection);
 255            return ownConnection;
 256        }
 257
 258        async function reportCurrentPage() {
 259            var connection = await ensureConnection();
 260            if (!isConnected(connection)) return;
 261            try {
 262                await connection.invoke('UpdateCurrentPage', window.location.pathname + window.location.search);
 263            } catch (err) {
 264                console.error('Не удалось отправить текущую страницу:', err);
 265            }
 266        }
 267
 268        document.addEventListener('DOMContentLoaded', function () {
 269            var toggle = document.querySelector('[data-ou-toggle]');
 270            var dropdown = toggle ? toggle.closest('.dropdown') : null;
 271            if (!dropdown) return;
 272
 273            dropdown.addEventListener('show.bs.dropdown', function () {
 274                refresh();
 275                // Пока меню открыто, обновляем длительность сеансов.
 276                clearInterval(refreshTimer);
 277                refreshTimer = setInterval(refresh, 30000);
 278            });
 279
 280            dropdown.addEventListener('hide.bs.dropdown', function () {
 281                clearInterval(refreshTimer);
 282                refreshTimer = null;
 283            });
 284
 285            setTimeout(function () {
 286                ensureConnection().then(reportCurrentPage);
 287            }, 1000);
 288        });
 289    })();
 290</script>

Methods/Properties

<ExecuteAsync()