Files
rmm-openwrt/web/app.js
T
benya c468a3cccb
Build and test / Tests and license checks (push) Has been cancelled
Build and test / OpenWrt 24.10.7 · x86-64 (push) Has been cancelled
Build and test / OpenWrt 25.12.4 · x86-64 (push) Has been cancelled
Build and test / OpenWrt 24.10.7 · ipq40xx-generic (push) Has been cancelled
Build and test / OpenWrt 25.12.4 · ipq40xx-generic (push) Has been cancelled
Build and test / OpenWrt 24.10.7 · mediatek-filogic (push) Has been cancelled
Build and test / OpenWrt 25.12.4 · mediatek-filogic (push) Has been cancelled
Build and test / OpenWrt 24.10.7 · ath79-generic (push) Has been cancelled
Build and test / OpenWrt 25.12.4 · ath79-generic (push) Has been cancelled
Build and test / OpenWrt 24.10.7 · ramips-mt7621 (push) Has been cancelled
Build and test / OpenWrt 25.12.4 · ramips-mt7621 (push) Has been cancelled
Build and test / Publish agent GitHub release assets (push) Has been cancelled
Build and test / Publish signed package repository (push) Has been cancelled
Release server / Test and build server image (push) Has been cancelled
release: prepare server 0.9.2
2026-07-31 11:30:51 +03:00

3347 lines
161 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = {
username: "",
user: null,
devices: [],
users: [],
passwordResetToken: "",
selectedDeviceId: null,
deviceTab: "overview",
filter: "all",
fleetSortKey: "status",
fleetSortDir: "desc",
clientFilter: "all",
alertStatusFilter: "open",
commandFilter: "all",
commandStatusFilter: "",
commandLimit: 25,
commandOffset: 0,
commandHasMore: false,
auditLimit: 25,
auditOffset: 0,
auditHasMore: false,
commands: [],
auditEvents: [],
alerts: [],
notificationSettings: null,
notificationChannels: {},
notifications: [],
notificationMetrics: {},
profileTab: "account",
inboxNotifications: [],
notificationUnread: 0,
lanClients: [],
remoteSessions: [],
selectedCommand: null,
presetReview: null,
luciAction: "setup",
mobileRoute: "fleet",
previousMobileRoute: "fleet",
liveConnected: false,
lastUpdatedAt: null,
releaseMetadata: null,
releaseMetadataCheckedAt: 0,
};
let eventSource = null;
let liveRefreshTimer = null;
const els = {
loginView: document.querySelector("#loginView"),
loginForm: document.querySelector("#loginForm"),
loginUsername: document.querySelector("#loginUsername"),
loginPassword: document.querySelector("#loginPassword"),
loginError: document.querySelector("#loginError"),
forgotPasswordBtn: document.querySelector("#forgotPasswordBtn"),
forgotPasswordDialog: document.querySelector("#forgotPasswordDialog"),
forgotPasswordForm: document.querySelector("#forgotPasswordForm"),
closeForgotPasswordBtn: document.querySelector("#closeForgotPasswordBtn"),
passwordResetIdentifier: document.querySelector("#passwordResetIdentifier"),
forgotPasswordMessage: document.querySelector("#forgotPasswordMessage"),
passwordResetDialog: document.querySelector("#passwordResetDialog"),
passwordResetForm: document.querySelector("#passwordResetForm"),
closePasswordResetBtn: document.querySelector("#closePasswordResetBtn"),
resetNewPassword: document.querySelector("#resetNewPassword"),
resetConfirmPassword: document.querySelector("#resetConfirmPassword"),
passwordResetMessage: document.querySelector("#passwordResetMessage"),
appShell: document.querySelector("#appShell"),
operatorName: document.querySelector("#operatorName"),
fleetNavBtn: document.querySelector("#fleetNavBtn"),
problemsNavBtn: document.querySelector("#problemsNavBtn"),
operationsNavBtn: document.querySelector("#operationsNavBtn"),
profileBtn: document.querySelector("#profileBtn"),
logoutBtn: document.querySelector("#logoutBtn"),
apiState: document.querySelector("#apiState"),
refreshBtn: document.querySelector("#refreshBtn"),
addRouterBtn: document.querySelector("#addRouterBtn"),
addUserBtn: document.querySelector("#addUserBtn"),
enrollmentGrantDialog: document.querySelector("#enrollmentGrantDialog"),
enrollmentTokenOutput: document.querySelector("#enrollmentTokenOutput"),
copyEnrollmentTokenBtn: document.querySelector("#copyEnrollmentTokenBtn"),
createUserDialog: document.querySelector("#createUserDialog"),
createUserForm: document.querySelector("#createUserForm"),
newUsername: document.querySelector("#newUsername"),
newUserEmail: document.querySelector("#newUserEmail"),
newUserPassword: document.querySelector("#newUserPassword"),
newUserRole: document.querySelector("#newUserRole"),
cancelCreateUserBtn: document.querySelector("#cancelCreateUserBtn"),
deviceList: document.querySelector("#deviceList"),
fleetView: document.querySelector("#fleetView"),
fleetTotalCount: document.querySelector("#fleetTotalCount"),
fleetOnlineCount: document.querySelector("#fleetOnlineCount"),
fleetOfflineCount: document.querySelector("#fleetOfflineCount"),
fleetAlertCount: document.querySelector("#fleetAlertCount"),
navAlertCount: document.querySelector("#navAlertCount"),
statusLine: document.querySelector("#statusLine"),
liveState: document.querySelector("#liveState"),
pageTitle: document.querySelector("#pageTitle"),
emptyState: document.querySelector("#emptyState"),
emptyStateTitle: document.querySelector("#emptyState h2"),
emptyStateDescription: document.querySelector("#emptyState p"),
deviceView: document.querySelector("#deviceView"),
backToFleetBtn: document.querySelector("#backToFleetBtn"),
updateAgentBtn: document.querySelector("#updateAgentBtn"),
agentUpdateStatus: document.querySelector("#agentUpdateStatus"),
quickDiagnosticBtn: document.querySelector("#quickDiagnosticBtn"),
openLuciBtn: document.querySelector("#openLuciBtn"),
remoteAccessPanel: document.querySelector("#remoteAccessPanel"),
runFullDiagnosticBtn: document.querySelector("#runFullDiagnosticBtn"),
diagnosticStatus: document.querySelector("#diagnosticStatus"),
deviceName: document.querySelector("#deviceName"),
deviceMeta: document.querySelector("#deviceMeta"),
deviceBadge: document.querySelector("#deviceBadge"),
informationStatus: document.querySelector("#informationStatus"),
infoHostname: document.querySelector("#infoHostname"),
infoModel: document.querySelector("#infoModel"),
infoOpenWrt: document.querySelector("#infoOpenWrt"),
infoTarget: document.querySelector("#infoTarget"),
infoSystem: document.querySelector("#infoSystem"),
infoKernel: document.querySelector("#infoKernel"),
infoAgentVersion: document.querySelector("#infoAgentVersion"),
infoCreatedAt: document.querySelector("#infoCreatedAt"),
infoLastSeenAt: document.querySelector("#infoLastSeenAt"),
infoDeviceId: document.querySelector("#infoDeviceId"),
infoBoardName: document.querySelector("#infoBoardName"),
infoRootfs: document.querySelector("#infoRootfs"),
healthSummary: document.querySelector("#healthSummary"),
deviceStatusHero: document.querySelector("#deviceStatusHero"),
agentHealthSummary: document.querySelector("#agentHealthSummary"),
lastSeen: document.querySelector("#lastSeen"),
loadAvg: document.querySelector("#loadAvg"),
uptime: document.querySelector("#uptime"),
defaultRoute: document.querySelector("#defaultRoute"),
wanIp: document.querySelector("#wanIp"),
memoryUsage: document.querySelector("#memoryUsage"),
diskUsage: document.querySelector("#diskUsage"),
clientCounts: document.querySelector("#clientCounts"),
connectivityStatus: document.querySelector("#connectivityStatus"),
inventoryJson: document.querySelector("#inventoryJson"),
clientList: document.querySelector("#clientList"),
clientSummary: document.querySelector("#clientSummary"),
clientSearch: document.querySelector("#clientSearch"),
interfaceCounters: document.querySelector("#interfaceCounters"),
networkSummary: document.querySelector("#networkSummary"),
networkHealth: document.querySelector("#networkHealth"),
fleetSearch: document.querySelector("#fleetSearch"),
fleetGroupFilter: document.querySelector("#fleetGroupFilter"),
fleetTagFilter: document.querySelector("#fleetTagFilter"),
fleetFilterToggle: document.querySelector("#fleetFilterToggle"),
fleetAdvancedFilters: document.querySelector("#fleetAdvancedFilters"),
bulkCommandType: document.querySelector("#bulkCommandType"),
bulkCommandTarget: document.querySelector("#bulkCommandTarget"),
sendBulkCommandBtn: document.querySelector("#sendBulkCommandBtn"),
fleetGroup: document.querySelector("#fleetGroup"),
fleetTags: document.querySelector("#fleetTags"),
saveFleetBtn: document.querySelector("#saveFleetBtn"),
alertSummary: document.querySelector("#alertSummary"),
alertList: document.querySelector("#alertList"),
alertStatusFilter: document.querySelector("#alertStatusFilter"),
metricsHistorySummary: document.querySelector("#metricsHistorySummary"),
metricsHistory: document.querySelector("#metricsHistory"),
commandType: document.querySelector("#commandType"),
commandTarget: document.querySelector("#commandTarget"),
sendCommandBtn: document.querySelector("#sendCommandBtn"),
packageCommand: document.querySelector("#packageCommand"),
packageName: document.querySelector("#packageName"),
sendPackageCommandBtn: document.querySelector("#sendPackageCommandBtn"),
remoteServerHost: document.querySelector("#remoteServerHost"),
remoteServerPort: document.querySelector("#remoteServerPort"),
remotePort: document.querySelector("#remotePort"),
remoteLocalPort: document.querySelector("#remoteLocalPort"),
remoteLuCIScheme: document.querySelector("#remoteLuCIScheme"),
remoteDuration: document.querySelector("#remoteDuration"),
createRemoteSessionBtn: document.querySelector("#createRemoteSessionBtn"),
openCloudAccessBtn: document.querySelector("#openCloudAccessBtn"),
cloudAccessCard: document.querySelector("#cloudAccessCard"),
cloudAccessStatus: document.querySelector("#cloudAccessStatus"),
remoteSummary: document.querySelector("#remoteSummary"),
remoteSessionList: document.querySelector("#remoteSessionList"),
uciConfig: document.querySelector("#uciConfig"),
uciSection: document.querySelector("#uciSection"),
uciOption: document.querySelector("#uciOption"),
uciValue: document.querySelector("#uciValue"),
uciCommitNow: document.querySelector("#uciCommitNow"),
uciBackupBtn: document.querySelector("#uciBackupBtn"),
uciPreviewBtn: document.querySelector("#uciPreviewBtn"),
uciShowBtn: document.querySelector("#uciShowBtn"),
uciSetBtn: document.querySelector("#uciSetBtn"),
uciCommitBtn: document.querySelector("#uciCommitBtn"),
uciCommitConfirmedBtn: document.querySelector("#uciCommitConfirmedBtn"),
uciRevertBtn: document.querySelector("#uciRevertBtn"),
uciRestoreBtn: document.querySelector("#uciRestoreBtn"),
presetLanIp: document.querySelector("#presetLanIp"),
presetHostname: document.querySelector("#presetHostname"),
presetWifiSsid: document.querySelector("#presetWifiSsid"),
presetWifiKey: document.querySelector("#presetWifiKey"),
presetDhcpLan: document.querySelector("#presetDhcpLan"),
presetReviewPanel: document.querySelector("#presetReviewPanel"),
presetReviewTitle: document.querySelector("#presetReviewTitle"),
presetReviewStatus: document.querySelector("#presetReviewStatus"),
presetReviewChange: document.querySelector("#presetReviewChange"),
presetReviewOutput: document.querySelector("#presetReviewOutput"),
cancelPresetReviewBtn: document.querySelector("#cancelPresetReviewBtn"),
applyPresetReviewBtn: document.querySelector("#applyPresetReviewBtn"),
commandSummary: document.querySelector("#commandSummary"),
commandStatusFilter: document.querySelector("#commandStatusFilter"),
loadMoreCommandsBtn: document.querySelector("#loadMoreCommandsBtn"),
loadMoreAuditBtn: document.querySelector("#loadMoreAuditBtn"),
auditSummary: document.querySelector("#auditSummary"),
commandList: document.querySelector("#commandList"),
commandDetailPanel: document.querySelector("#commandDetailPanel"),
commandDetailMeta: document.querySelector("#commandDetailMeta"),
commandDetailOutput: document.querySelector("#commandDetailOutput"),
copyCommandOutputBtn: document.querySelector("#copyCommandOutputBtn"),
auditList: document.querySelector("#auditList"),
clearAlertsBtn: document.querySelector("#clearAlertsBtn"),
clearCommandsBtn: document.querySelector("#clearCommandsBtn"),
clearAuditBtn: document.querySelector("#clearAuditBtn"),
deleteDeviceBtn: document.querySelector("#deleteDeviceBtn"),
deviceTransferForm: document.querySelector("#deviceTransferForm"),
transferUsername: document.querySelector("#transferUsername"),
transferPassword: document.querySelector("#transferPassword"),
transferMessage: document.querySelector("#transferMessage"),
toastRegion: document.querySelector("#toastRegion"),
profileDialog: document.querySelector("#profileDialog"),
closeProfileBtn: document.querySelector("#closeProfileBtn"),
profileUsername: document.querySelector("#profileUsername"),
profileRole: document.querySelector("#profileRole"),
profileAvatar: document.querySelector("#profileAvatar"),
profileLogoutBtn: document.querySelector("#profileLogoutBtn"),
profileForm: document.querySelector("#profileForm"),
profileDisplayName: document.querySelector("#profileDisplayName"),
profileEmail: document.querySelector("#profileEmail"),
profileMessage: document.querySelector("#profileMessage"),
profileAdminTab: document.querySelector("#profileAdminTab"),
profileTabs: [...document.querySelectorAll("[data-profile-tab]")],
profilePanels: [...document.querySelectorAll("[data-profile-panel]")],
rolloutForm: document.querySelector("#rolloutForm"),
rolloutDeviceIds: document.querySelector("#rolloutDeviceIds"),
rolloutChannel: document.querySelector("#rolloutChannel"),
rolloutBatchSize: document.querySelector("#rolloutBatchSize"),
rolloutFailureThreshold: document.querySelector("#rolloutFailureThreshold"),
rolloutMessage: document.querySelector("#rolloutMessage"),
rolloutList: document.querySelector("#rolloutList"),
passwordForm: document.querySelector("#passwordForm"),
currentPassword: document.querySelector("#currentPassword"),
newPassword: document.querySelector("#newPassword"),
confirmPassword: document.querySelector("#confirmPassword"),
passwordMessage: document.querySelector("#passwordMessage"),
notificationSettingsForm: document.querySelector("#notificationSettingsForm"),
notificationEmailEnabled: document.querySelector("#notificationEmailEnabled"),
notificationEmailHint: document.querySelector("#notificationEmailHint"),
notificationTelegramEnabled: document.querySelector("#notificationTelegramEnabled"),
notificationTelegramHint: document.querySelector("#notificationTelegramHint"),
notificationTelegramChatRow: document.querySelector("#notificationTelegramChatRow"),
notificationTelegramChatId: document.querySelector("#notificationTelegramChatId"),
verifyEmailBtn: document.querySelector("#verifyEmailBtn"),
verifyTelegramBtn: document.querySelector("#verifyTelegramBtn"),
emailVerificationCode: document.querySelector("#emailVerificationCode"),
telegramVerificationCode: document.querySelector("#telegramVerificationCode"),
confirmEmailBtn: document.querySelector("#confirmEmailBtn"),
confirmTelegramBtn: document.querySelector("#confirmTelegramBtn"),
notificationWarningEnabled: document.querySelector("#notificationWarningEnabled"),
notificationCriticalEnabled: document.querySelector("#notificationCriticalEnabled"),
notificationResolvedEnabled: document.querySelector("#notificationResolvedEnabled"),
notificationMemoryThreshold: document.querySelector("#notificationMemoryThreshold"),
notificationDiskThreshold: document.querySelector("#notificationDiskThreshold"),
notificationPacketLossThreshold: document.querySelector("#notificationPacketLossThreshold"),
notificationLatencyThreshold: document.querySelector("#notificationLatencyThreshold"),
notificationRepeatMinutes: document.querySelector("#notificationRepeatMinutes"),
notificationTimezone: document.querySelector("#notificationTimezone"),
notificationPausedUntil: document.querySelector("#notificationPausedUntil"),
notificationQuietEnabled: document.querySelector("#notificationQuietEnabled"),
notificationQuietStart: document.querySelector("#notificationQuietStart"),
notificationQuietEnd: document.querySelector("#notificationQuietEnd"),
notificationWebhookEnabled: document.querySelector("#notificationWebhookEnabled"),
notificationWebhookUrl: document.querySelector("#notificationWebhookUrl"),
notificationWebhookSecret: document.querySelector("#notificationWebhookSecret"),
notificationDeviceSelect: document.querySelector("#notificationDeviceSelect"),
notificationDeviceEnabled: document.querySelector("#notificationDeviceEnabled"),
notificationDeviceCritical: document.querySelector("#notificationDeviceCritical"),
notificationDeviceWarning: document.querySelector("#notificationDeviceWarning"),
notificationDeviceResolved: document.querySelector("#notificationDeviceResolved"),
notificationDevicePausedUntil: document.querySelector("#notificationDevicePausedUntil"),
saveDeviceNotificationSettingsBtn: document.querySelector("#saveDeviceNotificationSettingsBtn"),
notificationSettingsMessage: document.querySelector("#notificationSettingsMessage"),
testNotificationsBtn: document.querySelector("#testNotificationsBtn"),
refreshNotificationsBtn: document.querySelector("#refreshNotificationsBtn"),
notificationMetrics: document.querySelector("#notificationMetrics"),
notificationChannelDiagnostics: document.querySelector("#notificationChannelDiagnostics"),
notificationFilterDevice: document.querySelector("#notificationFilterDevice"),
notificationFilterSeverity: document.querySelector("#notificationFilterSeverity"),
notificationFilterEvent: document.querySelector("#notificationFilterEvent"),
notificationFilterChannel: document.querySelector("#notificationFilterChannel"),
notificationFilterStatus: document.querySelector("#notificationFilterStatus"),
clearNotificationFiltersBtn: document.querySelector("#clearNotificationFiltersBtn"),
notificationHistory: document.querySelector("#notificationHistory"),
notificationCenterBtn: document.querySelector("#notificationCenterBtn"),
notificationUnreadCount: document.querySelector("#notificationUnreadCount"),
mobileNotificationUnreadCount: document.querySelector("#mobileNotificationUnreadCount"),
notificationCenterDialog: document.querySelector("#notificationCenterDialog"),
closeNotificationCenterBtn: document.querySelector("#closeNotificationCenterBtn"),
markAllNotificationsReadBtn: document.querySelector("#markAllNotificationsReadBtn"),
notificationCenterSummary: document.querySelector("#notificationCenterSummary"),
notificationCenterList: document.querySelector("#notificationCenterList"),
logoutAllBtn: document.querySelector("#logoutAllBtn"),
userManagementSection: document.querySelector("#userManagementSection"),
userList: document.querySelector("#userList"),
luciStateDialog: document.querySelector("#luciStateDialog"),
luciStateCode: document.querySelector("#luciStateCode"),
luciStateTitle: document.querySelector("#luciStateTitle"),
luciStateDescription: document.querySelector("#luciStateDescription"),
luciStateContext: document.querySelector("#luciStateContext"),
luciStatePrimaryBtn: document.querySelector("#luciStatePrimaryBtn"),
luciStateDiagnosticBtn: document.querySelector("#luciStateDiagnosticBtn"),
closeLuciStateBtn: document.querySelector("#closeLuciStateBtn"),
luciStateRequestId: document.querySelector("#luciStateRequestId"),
};
if (els.remoteServerHost) {
els.remoteServerHost.value = window.location.hostname || "10.10.10.2";
}
function ensureToastRegion() {
if (els.toastRegion) return els.toastRegion;
const region = document.createElement("div");
region.id = "toastRegion";
region.className = "toast-region";
region.setAttribute("aria-live", "polite");
region.setAttribute("aria-atomic", "true");
document.body.appendChild(region);
els.toastRegion = region;
return region;
}
function showToast(message, tone = "info") {
if (!message) return;
const region = ensureToastRegion();
const toast = document.createElement("div");
toast.className = `toast ${tone}`;
toast.textContent = message;
region.appendChild(toast);
window.setTimeout(() => {
toast.classList.add("is-leaving");
window.setTimeout(() => toast.remove(), 180);
}, 2800);
}
function setStatus(message) {
els.statusLine.textContent = message;
}
function notify(message, tone = "info") {
setStatus(message);
showToast(message, tone);
}
function reportError(error) {
const message = error instanceof Error ? error.message : String(error || "Unexpected error");
setStatus(message);
showToast(message, "error");
}
function setLiveState(mode, label) {
state.liveConnected = mode === "live";
els.liveState.className = `live-state is-${mode}`;
els.liveState.querySelector("span").textContent = label;
}
function lastUpdatedLabel() {
if (!state.lastUpdatedAt) return state.liveConnected ? "Живые данные" : "Ожидание данных";
const elapsed = Math.max(0, Math.floor((Date.now() - state.lastUpdatedAt.getTime()) / 1000));
if (elapsed < 10) return state.liveConnected ? "Живые данные · сейчас" : "Polling · сейчас";
if (elapsed < 60) return `${state.liveConnected ? "Живые данные" : "Polling"} · ${elapsed} сек.`;
return `${state.liveConnected ? "Живые данные" : "Polling"} · ${Math.floor(elapsed / 60)} мин.`;
}
function updateLiveStateLabel() {
if (els.appShell.classList.contains("is-hidden")) return;
setLiveState(state.liveConnected ? "live" : "polling", lastUpdatedLabel());
}
function disconnectLiveUpdates() {
if (eventSource) eventSource.close();
if (liveRefreshTimer) window.clearTimeout(liveRefreshTimer);
eventSource = null;
liveRefreshTimer = null;
state.liveConnected = false;
}
function scheduleLiveRefresh() {
if (liveRefreshTimer) window.clearTimeout(liveRefreshTimer);
liveRefreshTimer = window.setTimeout(() => {
liveRefreshTimer = null;
refreshDevicesIfIdle().catch(() => setLiveState("polling", "Polling · 30 сек."));
}, 750);
}
function connectLiveUpdates() {
disconnectLiveUpdates();
if (!("EventSource" in window) || els.appShell.classList.contains("is-hidden")) {
setLiveState("polling", "Polling · 30 сек.");
return;
}
setLiveState("connecting", "Подключение");
eventSource = new EventSource("/api/events");
eventSource.addEventListener("ready", () => {
state.liveConnected = true;
setLiveState("live", lastUpdatedLabel());
});
eventSource.addEventListener("devices", () => {
if (document.visibilityState !== "visible") return;
scheduleLiveRefresh();
});
eventSource.addEventListener("notifications", () => {
if (document.visibilityState !== "visible") return;
loadNotificationCenter().catch(() => {});
if (els.profileDialog.open && state.profileTab === "notifications") {
loadNotificationHistory().catch(() => {});
}
});
eventSource.onerror = () => {
state.liveConnected = false;
setLiveState("polling", "Polling · 30 сек.");
};
}
function inlineStateMarkup(title, description = "", tone = "neutral") {
return `
<div class="inline-state ${tone}">
<strong>${escapeHtml(title)}</strong>
${description ? `<small>${escapeHtml(description)}</small>` : ""}
</div>
`;
}
async function api(path, options = {}) {
const headers = {
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
};
const response = await fetch(path, { ...options, credentials: "same-origin", headers });
const requestId = response.headers.get("X-Request-ID");
const text = await response.text();
let data = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
if (!response.ok) {
if (response.status === 401 && !path.startsWith("/api/auth/")) {
showLogin();
}
const rawMessage = data && data.error ? data.error : `HTTP ${response.status}`;
const message = friendlyAPIError(response.status, data && data.code, rawMessage);
const suffix = requestId ? ` (${requestId})` : "";
const error = new Error(`${message}${suffix}`);
error.status = response.status;
error.requestId = requestId || "";
error.code = data && data.code ? data.code : "";
throw error;
}
return data;
}
function friendlyAPIError(status, code, rawMessage) {
const byCode = {
device_offline: "Роутер не на связи",
not_configured: "Облачный доступ ещё не настроен",
grant_limit: "Создано слишком много временных ссылок. Подождите минуту",
restart_failed: "Не удалось перезапустить защищённый туннель",
create_failed: "Не удалось создать защищённый туннель",
};
if (code && byCode[code]) return byCode[code];
const known = {
"invalid username or password": "Неверный логин или пароль",
"invalid current password": "Текущий пароль указан неверно",
"device not found": "Роутер не найден или у вас нет к нему доступа",
"invalid or expired enrollment grant": "Код подключения недействителен или уже использован",
"active LuCI access grant limit reached": "Создано слишком много временных ссылок. Подождите минуту",
"agent update is unavailable": "Управляемое обновление агента сейчас недоступно",
"device does not support managed agent updates": "Этот роутер не поддерживает управляемое обновление агента",
"no compatible immutable agent feed is available": "Для этого роутера нет совместимой версии агента",
"failed to queue agent update": "Не удалось добавить обновление агента в очередь",
};
if (known[rawMessage]) return known[rawMessage];
if (String(rawMessage).startsWith("failed to ")) {
return status >= 500 ? "Сервер временно не смог выполнить запрос" : "Не удалось выполнить запрос";
}
return rawMessage;
}
function showLogin(message = "") {
disconnectLiveUpdates();
document.title = "Вход — OpenWrt RMM";
els.appShell.classList.add("is-hidden");
els.appShell.hidden = true;
els.appShell.setAttribute("aria-hidden", "true");
els.loginView.classList.remove("is-hidden");
els.loginView.hidden = false;
els.loginView.removeAttribute("aria-hidden");
els.loginError.textContent = message;
els.loginPassword.value = "";
if (location.pathname === "/app") history.replaceState(null, "", `/login${location.hash}`);
if (message) document.querySelector("#login")?.scrollIntoView({ behavior: "smooth", block: "center" });
}
function showApp(user) {
document.title = "Кабинет — OpenWrt RMM";
state.user = user || null;
state.username = user && user.username ? user.username : "operator";
const accountName = user && user.display_name ? user.display_name : state.username;
els.operatorName.textContent = accountName;
els.profileUsername.textContent = state.username;
els.profileRole.textContent = user && user.role === "admin" ? "Администратор" : "Пользователь";
const initial = accountName.trim().charAt(0).toUpperCase() || "О";
els.profileAvatar.textContent = initial;
document.querySelector(".operator-avatar").textContent = initial;
els.addUserBtn.classList.toggle("is-hidden", !user || user.role !== "admin");
els.profileAdminTab.classList.toggle("is-hidden", !user || user.role !== "admin");
els.loginView.classList.add("is-hidden");
els.loginView.hidden = true;
els.loginView.setAttribute("aria-hidden", "true");
els.appShell.classList.remove("is-hidden");
els.appShell.hidden = false;
els.appShell.removeAttribute("aria-hidden");
els.loginError.textContent = "";
if (location.pathname !== "/app") history.replaceState(null, "", `/app${location.hash}`);
connectLiveUpdates();
loadNotificationCenter().catch(() => {});
}
function formatLoadAverage(value) {
const values = String(value || "").trim().split(/\s+/).slice(0, 3).map(Number);
if (values.length !== 3 || values.some((item) => !Number.isFinite(item))) return "-";
return values.map((item) => item.toFixed(2)).join(" · ");
}
function formatUptime(value) {
const seconds = Number.parseFloat(String(value || "").trim().split(/\s+/)[0]);
if (!Number.isFinite(seconds) || seconds < 0) return "-";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days} дн. ${hours} ч.`;
if (hours > 0) return `${hours} ч. ${minutes} мин.`;
return `${minutes} мин.`;
}
async function checkSession() {
try {
const me = await api("/api/auth/me");
showApp(me.user || { username: me.username, role: "user" });
await loadReleaseMetadata();
await loadDevices();
} catch {
showLogin();
}
}
async function login() {
els.loginError.textContent = "";
const response = await api("/api/auth/login", {
method: "POST",
body: JSON.stringify({
username: els.loginUsername.value.trim(),
password: els.loginPassword.value,
}),
});
showApp(response.user || { username: response.username, role: "user" });
await loadReleaseMetadata();
await loadDevices();
}
async function logout() {
await api("/api/auth/logout", { method: "POST" });
state.devices = [];
state.user = null;
state.selectedDeviceId = null;
showLogin();
}
function formatDate(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString();
}
function formatShortDate(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function statusLabel(status) {
return {
requested: "Запрошено",
queued: "В очереди",
active: "Активно",
closed: "Закрыто",
claimed: "Выполняется",
completed: "Готово",
failed: "Ошибка",
cancelled: "Отменено",
expired: "Истекло",
}[status] || status || "-";
}
function commandTypeLabel(type) {
return {
ping: "Проверка связи",
traceroute: "Маршрут до узла",
route_show: "Показ маршрутов",
interfaces_show: "Показ интерфейсов",
reboot: "Перезагрузка",
service_restart: "Перезапуск сервиса",
pkg_list_installed: "Список пакетов",
pkg_update: "Обновление индекса пакетов",
pkg_list_upgradable: "Доступные обновления пакетов",
pkg_install: "Установка пакета",
pkg_remove: "Удаление пакета",
opkg_list_installed: "Список пакетов",
opkg_update: "Обновление индекса пакетов",
opkg_list_upgradable: "Доступные обновления пакетов",
opkg_install: "Установка пакета",
opkg_remove: "Удаление пакета",
uci_show: "Просмотр UCI",
uci_backup: "Резервная копия UCI",
uci_preview: "Проверка изменения настроек",
uci_set: "Подготовка изменения настроек",
uci_commit: "Применение настроек",
uci_commit_confirmed: "Безопасное применение настроек",
uci_revert: "Отмена staged-настроек",
uci_restore: "Восстановление настроек",
remote_ssh_reverse: "Открытие удаленного доступа",
remote_ssh_close: "Закрытие удаленного доступа",
agent_update: "Обновление агента",
}[type] || type || "-";
}
function commandFilterMatches(command) {
if (state.commandStatusFilter && command.status !== state.commandStatusFilter) return false;
if (state.commandFilter === "running") return command.status === "queued" || command.status === "claimed";
if (state.commandFilter === "failed") return ["failed", "expired", "cancelled"].includes(command.status);
if (state.commandFilter === "completed") return command.status === "completed";
return true;
}
function remoteStatusLabel(status) {
return {
requested: "Запрашивается",
queued: "Открывается",
active: "Активен",
closed: "Закрыт",
failed: "Ошибка",
expired: "Завершен",
}[status] || statusLabel(status);
}
function alertTypeLabel(type) {
return {
offline: "Роутер не выходит на связь",
memory_high: "Мало свободной памяти",
disk_high: "Мало места на диске",
wan_ip_changed: "Изменился WAN IP",
command_attention: "Есть проблемные команды",
wan_down: "Нет связи с WAN",
packet_loss_high: "Потери пакетов",
latency_high: "Высокая задержка",
}[type] || type || "-";
}
function dangerLabel(type) {
return {
reboot: "перезагрузить роутер",
pkg_remove: "удалить пакет",
opkg_remove: "удалить пакет",
uci_set: "изменить конфигурацию",
uci_commit: "применить конфигурацию",
uci_commit_confirmed: "применить конфигурацию с проверкой связи",
uci_restore: "восстановить конфигурацию из backup",
}[type] || "";
}
function confirmDanger(type) {
const label = dangerLabel(type);
if (!label) return true;
return window.confirm(`Подтвердите действие: ${label}. Продолжить?`);
}
function confirmTyped(message, expected) {
const value = window.prompt(`${message}\n\nВведите ${expected}, чтобы подтвердить.`);
return value === expected;
}
function commandArgs() {
const type = els.commandType.value;
if (type === "pkg_list_installed" || type === "route_show" || type === "interfaces_show" || type === "reboot") return {};
const target = els.commandTarget.value.trim() || "1.1.1.1";
return { target };
}
function bulkCommandArgs() {
const type = els.bulkCommandType.value;
if (type === "pkg_list_installed") return {};
const target = els.bulkCommandTarget.value.trim() || "1.1.1.1";
return { target };
}
function currentDevice() {
return state.devices.find((device) => device.id === state.selectedDeviceId) || null;
}
function deviceDisplayName(device) {
if (!device) return "-";
const inventoryHostname = device.inventory && device.inventory.hostname;
const boardHostname = device.inventory && device.inventory.board && device.inventory.board.hostname;
for (const value of [inventoryHostname, boardHostname, device.hostname]) {
if (value && value !== "unknown") return value;
}
return device.id || "unknown";
}
function deviceModel(device) {
return device && device.inventory && device.inventory.board && device.inventory.board.model
? device.inventory.board.model
: "-";
}
function deviceAgentVersion(device) {
return device && device.inventory && device.inventory.agent_version ? String(device.inventory.agent_version) : "";
}
function supportsManagedAgentUpdate(device) {
const inventory = device && device.inventory;
return inventory && inventory.agent_runtime === "go" && inventory.agent_package === "rmm-agent-go-production";
}
function hasAgentUpdateAvailable(device) {
return supportsManagedAgentUpdate(device) && agentVersionState(deviceAgentVersion(device)).comparison === -1;
}
function stableAgentVersion() {
return state.releaseMetadata && state.releaseMetadata.stable_agent_version
? String(state.releaseMetadata.stable_agent_version)
: "";
}
function agentVersionState(version) {
const stable = stableAgentVersion();
if (!version || !stable || !globalThis.RMMVersions) return { comparison: null, stable };
return { comparison: globalThis.RMMVersions.compareSemVer(version, stable), stable };
}
function agentVersionLabel(version) {
if (!version) return "Версия не определена";
const versionState = agentVersionState(version);
if (versionState.comparison === -1) return `${version} · доступна ${versionState.stable}`;
if (versionState.comparison === 0) return `${version} · актуальная`;
if (versionState.comparison === 1) return `${version} · новее стабильной ${versionState.stable}`;
return `${version} · стабильная версия не определена`;
}
async function loadReleaseMetadata(force = false) {
if (!force && Date.now() - state.releaseMetadataCheckedAt < 5 * 60 * 1000) return;
state.releaseMetadataCheckedAt = Date.now();
try {
state.releaseMetadata = await api("/api/meta");
} catch {
state.releaseMetadata = null;
}
}
function deviceClientCount(device) {
const clients = normalizedClients(device);
const online = clients.filter((client) => client.presence === "online").length;
const unconfirmed = clients.length - online;
return { online, unconfirmed, total: clients.length };
}
function deviceWanSummary(device) {
const wanIP = device.inventory && device.inventory.wan_ip;
const checks = Array.isArray(device.metrics && device.metrics.connectivity_checks) ? device.metrics.connectivity_checks : [];
if (!device.online) return { label: "Нет данных", className: "offline" };
if (checks.some((check) => !check.reachable)) return { label: wanIP || "Есть потери связи", className: "warning" };
return { label: wanIP || "Подключено", className: "online" };
}
function getFleetSortValue(device, key) {
switch (key) {
case "status":
return device.online ? 1 : 0;
case "name":
return deviceDisplayName(device).toLowerCase();
case "model":
return `${deviceModel(device)} ${device.group || ""}`.toLowerCase();
case "wan":
return deviceWanSummary(device).label.toLowerCase();
case "clients":
return deviceClientCount(device).total;
case "alerts":
return Number(device.active_alerts || 0);
case "last_seen":
return Date.parse(device.last_seen_at || "") || 0;
default:
return deviceDisplayName(device).toLowerCase();
}
}
function sortDevices(devices) {
const direction = state.fleetSortDir === "asc" ? 1 : -1;
return [...devices].sort((left, right) => {
const leftValue = getFleetSortValue(left, state.fleetSortKey);
const rightValue = getFleetSortValue(right, state.fleetSortKey);
if (leftValue === rightValue) {
return deviceDisplayName(left).localeCompare(deviceDisplayName(right), undefined, { sensitivity: "base" }) * direction;
}
if (typeof leftValue === "number" && typeof rightValue === "number") {
return (leftValue - rightValue) * direction;
}
return String(leftValue).localeCompare(String(rightValue), undefined, { numeric: true, sensitivity: "base" }) * direction;
});
}
function renderFleetSortButtons() {
for (const button of document.querySelectorAll(".table-sort")) {
const isActive = button.dataset.sortKey === state.fleetSortKey;
button.classList.toggle("is-active", isActive);
button.dataset.sortDir = isActive ? state.fleetSortDir : button.dataset.sortDefaultDir || "asc";
}
}
function initFleetTableSorting() {
const head = document.querySelector(".fleet-table-head");
if (!head || head.querySelector(".table-sort")) return;
const labels = [
["status", "desc"],
["name", "asc"],
["model", "asc"],
["wan", "asc"],
["clients", "desc"],
["alerts", "desc"],
["last_seen", "desc"],
];
const cells = Array.from(head.children);
cells.forEach((cell, index) => {
if (index >= labels.length) return;
const [sortKey, defaultDir] = labels[index];
const button = document.createElement("button");
button.type = "button";
button.className = "table-sort";
button.dataset.sortKey = sortKey;
button.dataset.sortDefaultDir = defaultDir;
button.textContent = cell.textContent || "";
cell.replaceWith(button);
});
}
function filteredDevices() {
const search = els.fleetSearch.value.trim().toLowerCase();
const group = els.fleetGroupFilter.value.trim().toLowerCase();
const tag = els.fleetTagFilter.value.trim().toLowerCase();
return state.devices.filter((device) => {
if (state.filter === "online" && !device.online) return false;
if (state.filter === "offline" && device.online) return false;
if (state.filter === "alerts" && !device.active_alerts) return false;
if (group && String(device.group || "").toLowerCase() !== group) return false;
if (tag && !((device.tags || []).map((item) => String(item).toLowerCase()).includes(tag))) return false;
if (search) {
const haystack = [
deviceDisplayName(device),
device.id,
device.openwrt_version,
deviceAgentVersion(device),
deviceModel(device),
device.inventory && device.inventory.wan_ip,
device.group,
...(device.tags || []),
].join(" ").toLowerCase();
if (!haystack.includes(search)) return false;
}
return true;
});
}
function renderDevices() {
const devices = sortDevices(filteredDevices());
els.deviceList.innerHTML = "";
const online = state.devices.filter((device) => device.online).length;
const alerts = state.devices.filter((device) => device.active_alerts).length;
els.fleetTotalCount.textContent = state.devices.length;
els.fleetOnlineCount.textContent = online;
els.fleetOfflineCount.textContent = state.devices.length - online;
els.fleetAlertCount.textContent = alerts;
els.navAlertCount.textContent = alerts;
renderFleetSortButtons();
if (devices.length === 0) {
const hasFilters = Boolean(
els.fleetSearch.value.trim()
|| els.fleetGroupFilter.value.trim()
|| els.fleetTagFilter.value.trim()
|| state.filter !== "all"
);
if (els.emptyStateTitle) {
els.emptyStateTitle.textContent = hasFilters ? "Ничего не найдено по текущим фильтрам" : "Устройства пока не подключены";
}
if (els.emptyStateDescription) {
els.emptyStateDescription.textContent = hasFilters
? "Снимите часть фильтров или измените запрос, чтобы снова увидеть устройства."
: "Дождитесь первого heartbeat от агента или проверьте подключение роутера к серверу.";
}
els.emptyState.classList.remove("is-hidden");
return;
}
els.emptyState.classList.add("is-hidden");
for (const device of devices) {
const displayName = deviceDisplayName(device);
const clients = deviceClientCount(device);
const wan = deviceWanSummary(device);
const item = document.createElement("button");
item.type = "button";
item.className = `device-item ${device.id === state.selectedDeviceId ? "is-selected" : ""}`;
item.dataset.deviceId = device.id;
item.setAttribute("aria-label", `Открыть роутер ${displayName}`);
item.innerHTML = `
<span class="fleet-status ${device.online ? "online" : "offline"}">
<i></i>${device.online ? "На связи" : "Не на связи"}
</span>
<div class="device-identity">
<strong>${escapeHtml(displayName)}</strong>
<small>${escapeHtml(device.openwrt_version || device.id)}</small>
</div>
<div class="device-context">
<span>${escapeHtml(deviceModel(device))}</span>
<small>${escapeHtml([device.group || "", ...(device.tags || [])].filter(Boolean).join(" / ") || "Без группы")}</small>
</div>
<div class="device-field">
<span class="device-field-label">WAN</span>
<span class="wan-state ${wan.className}">${escapeHtml(wan.label)}</span>
</div>
<div class="device-field">
<span class="device-field-label">Онлайн / всего</span>
<span class="client-count">${clients.online} / ${clients.total}</span>
</div>
<span class="${device.active_alerts ? "problem-count has-problems" : "problem-count"}">
${device.active_alerts ? `${device.active_alerts} активн.` : "Нет"}
</span>
<span class="last-contact">${escapeHtml(formatDate(device.last_seen_at))}</span>
<span class="row-chevron"></span>
`;
item.addEventListener("click", () => selectDevice(device.id));
els.deviceList.appendChild(item);
}
}
function renderDeviceDetail(device) {
els.appShell.classList.toggle("has-selected-device", Boolean(device));
if (!device) {
els.fleetView.classList.remove("is-hidden");
els.fleetView.hidden = false;
els.deviceView.classList.add("is-hidden");
els.deviceView.hidden = true;
els.pageTitle.textContent = "Объекты";
return;
}
els.fleetView.classList.add("is-hidden");
els.fleetView.hidden = true;
els.deviceView.classList.remove("is-hidden");
els.deviceView.hidden = false;
renderDeviceTab();
const displayName = deviceDisplayName(device);
els.pageTitle.textContent = displayName;
els.deviceName.textContent = displayName;
els.deviceMeta.textContent = `${device.id} - ${device.openwrt_version || "unknown"}${device.domain_name ? ` - ${device.domain_name}` : ""}`;
els.deviceBadge.textContent = device.online ? "На связи" : "Не на связи";
els.deviceBadge.className = `badge ${device.online ? "online" : "offline"}`;
els.updateAgentBtn.classList.toggle("is-hidden", !hasAgentUpdateAvailable(device));
els.lastSeen.textContent = formatDate(device.last_seen_at);
els.loadAvg.textContent = formatLoadAverage(device.metrics && device.metrics.loadavg);
els.uptime.textContent = formatUptime(device.metrics && device.metrics.uptime);
els.defaultRoute.textContent = device.inventory && device.inventory.default_route ? device.inventory.default_route : "-";
els.wanIp.textContent = device.inventory && device.inventory.wan_ip ? device.inventory.wan_ip : "-";
els.memoryUsage.textContent = formatMemory(device.metrics && device.metrics.memory);
els.diskUsage.textContent = formatDisk(device.metrics && device.metrics.disk);
const clientCounts = deviceClientCount(device);
els.clientCounts.textContent = `${clientCounts.online} в сети / ${clientCounts.total} известно`;
els.connectivityStatus.textContent = formatConnectivity(device.metrics && device.metrics.connectivity_checks);
els.fleetGroup.value = device.group || "";
els.fleetTags.value = Array.isArray(device.tags) ? device.tags.join(", ") : "";
els.inventoryJson.textContent = JSON.stringify(device.inventory || {}, null, 2);
renderDeviceInformation(device);
renderDeviceStatus(device);
renderAgentUpdateStatus();
renderHealthSummary(device);
renderClients(device);
renderInterfaceCounters(device);
}
function renderAgentUpdateStatus() {
const command = state.commands.find((item) => item.type === "agent_update");
const pending = command && (command.status === "queued" || command.status === "claimed");
els.updateAgentBtn.classList.toggle("is-hidden", !hasAgentUpdateAvailable(currentDevice()) || pending);
if (!command) {
els.agentUpdateStatus.classList.add("is-hidden");
els.agentUpdateStatus.innerHTML = "";
return;
}
const running = pending;
const targetVersion = command.args && command.args.target_version;
const output = command.output ? ` ${outputSummary(command.output)}` : "";
const title = running ? "Обновление агента выполняется" : `Обновление агента: ${statusLabel(command.status)}`;
const detail = running
? `${statusLabel(command.status)}${targetVersion ? ` до версии ${targetVersion}` : ""}. Попытка ${command.attempt_count || 0}/${command.max_attempts || 3}.${output}`
: `${targetVersion ? `Целевая версия ${targetVersion}. ` : ""}${output || "Смотрите результат в истории команд."}`;
els.agentUpdateStatus.classList.remove("is-hidden");
els.agentUpdateStatus.innerHTML = `
<span class="operation-icon">${running ? "↻" : command.status === "completed" ? "✓" : "!"}</span>
<div><strong>${escapeHtml(title)}</strong><small>${escapeHtml(detail)}</small></div>
`;
}
function renderDeviceInformation(device) {
const board = device.inventory && device.inventory.board ? device.inventory.board : {};
const release = board.release || {};
els.informationStatus.textContent = device.online ? "На связи" : "Не на связи";
els.informationStatus.className = `badge ${device.online ? "online" : "offline"}`;
els.infoHostname.textContent = deviceDisplayName(device);
els.infoModel.textContent = board.model || "-";
els.infoOpenWrt.textContent = release.description || device.openwrt_version || "-";
els.infoTarget.textContent = release.target || "-";
els.infoSystem.textContent = board.system || "-";
els.infoKernel.textContent = board.kernel || "-";
els.infoAgentVersion.textContent = agentVersionLabel(deviceAgentVersion(device));
els.infoCreatedAt.textContent = formatDate(device.created_at);
els.infoLastSeenAt.textContent = formatDate(device.last_seen_at);
els.infoDeviceId.textContent = device.id || "-";
els.infoBoardName.textContent = board.board_name || "-";
els.infoRootfs.textContent = board.rootfs_type || "-";
}
function formatMemory(memory) {
if (!memory || !memory.total_kb) return "-";
return `${kbToMb(memory.used_kb)} / ${kbToMb(memory.total_kb)} MB`;
}
function formatDisk(disk) {
if (!disk || !disk.total_kb) return "-";
return `${disk.used_percent || "-"} (${kbToMb(disk.used_kb)} / ${kbToMb(disk.total_kb)} MB)`;
}
function formatConnectivity(checks) {
if (!Array.isArray(checks) || checks.length === 0) return "-";
const reachable = checks.filter((check) => check.reachable).length;
const reachableChecks = checks.filter((check) => check.reachable);
const worstReachableLatency = Math.max(0, ...reachableChecks.map((check) => Number(check.latency_ms || 0)));
const downTargets = checks.filter((check) => !check.reachable).map((check) => check.target).filter(Boolean);
const lossyChecks = reachableChecks.filter((check) => Number(check.packet_loss_percent || 0) > 0);
const worstLossy = lossyChecks.sort((a, b) => Number(b.packet_loss_percent || 0) - Number(a.packet_loss_percent || 0))[0];
if (downTargets.length > 0) {
return `${reachable}/${checks.length} up / ${worstReachableLatency} ms / down: ${downTargets.join(", ")}`;
}
if (worstLossy) {
return `${reachable}/${checks.length} up / ${worstReachableLatency} ms / loss: ${worstLossy.packet_loss_percent}% on ${worstLossy.target}`;
}
return `${reachable}/${checks.length} up / ${worstReachableLatency} ms / no loss`;
}
function formatRelativeTime(value) {
const timestamp = Date.parse(value || "");
if (!Number.isFinite(timestamp)) return "время неизвестно";
const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
if (seconds < 60) return `${seconds} сек. назад`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} мин. назад`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} ч. назад`;
return `${Math.floor(hours / 24)} дн. назад`;
}
function humanizeAgentError(value) {
const message = String(value || "").trim();
if (!message) return "Ошибок обмена не зафиксировано";
const normalized = message.toLowerCase();
if (normalized.includes("x509") || normalized.includes("certificate")) {
return "Ошибка TLS-сертификата: имя или цепочка сертификата не совпадает с адресом сервера";
}
if (normalized.includes("no such host") || normalized.includes("bad address")) {
return "DNS не смог определить адрес RMM-сервера";
}
if (normalized.includes("connection refused")) return "RMM-сервер отклонил подключение";
if (normalized.includes("timeout") || normalized.includes("deadline exceeded")) return "RMM-сервер не ответил вовремя";
if (normalized.includes("401") || normalized.includes("unauthorized")) return "Сервер отклонил ключ устройства";
if (normalized.includes("403") || normalized.includes("forbidden")) return "Сервер запретил запрос агента";
return message;
}
function renderDeviceStatus(device) {
const checks = Array.isArray(device.metrics && device.metrics.connectivity_checks) ? device.metrics.connectivity_checks : [];
const serverHost = (device.metrics && device.metrics.server_check_target) || window.location.hostname;
const serverCheck = checks.find((check) => check.target === serverHost);
const wanChecks = checks.filter((check) => check.target !== serverHost);
const version = deviceAgentVersion(device);
const agentHealth = device.metrics && device.metrics.agent_health ? device.metrics.agent_health : null;
const pending = Number(agentHealth && agentHealth.pending_results || 0);
const failures = Number(agentHealth && agentHealth.consecutive_failures || 0);
let tone = "good";
let title = "Роутер работает нормально";
let description = `Heartbeat получен ${formatRelativeTime(device.last_seen_at)}. Интернет и связь с сервером доступны.`;
let icon = "✓";
if (!device.online) {
tone = "bad";
title = "Агент не выходит на связь";
description = `Последний heartbeat был ${formatRelativeTime(device.last_seen_at)}. Проверьте службу агента, DNS, время роутера и TLS-сертификат.`;
icon = "!";
} else if (serverCheck && !serverCheck.reachable) {
tone = "bad";
title = "Проблема связи с RMM-сервером";
description = "Heartbeat дошёл, но проверка адреса сервера с роутера завершается ошибкой.";
icon = "!";
} else if (wanChecks.length && !wanChecks.some((check) => check.reachable)) {
tone = "bad";
title = "Интернет с роутера недоступен";
description = "Агент подключён к серверу, но внешние контрольные адреса не отвечают.";
icon = "!";
} else if (failures > 0) {
tone = "warn";
title = "Агент восстанавливает связь";
description = `Неудачных попыток подряд: ${failures}. Интервал повторов временно увеличен.`;
icon = "↻";
} else if (pending > 0) {
tone = "warn";
title = "Есть данные, ожидающие отправки";
description = `Агент хранит ${pending} ${resultWord(pending)} выполнения команд локально и повторит отправку автоматически.`;
icon = "↻";
} else if (Number(device.active_alerts || 0) > 0) {
tone = "warn";
title = "Роутер требует внимания";
description = `Активных проблем: ${device.active_alerts}. Подробности находятся ниже в журнале проблем.`;
icon = "!";
} else if (agentVersionState(version).comparison === -1) {
const stable = stableAgentVersion();
tone = "warn";
title = "Работает, доступно обновление агента";
description = `Установлена версия ${version}; актуальная стабильная версия — ${stable}.`;
icon = "↻";
}
els.deviceStatusHero.className = `device-status-hero ${tone}`;
els.deviceStatusHero.innerHTML = `
<span class="device-status-icon">${icon}</span>
<div><strong>${escapeHtml(title)}</strong><p>${escapeHtml(description)}</p></div>
`;
const lastError = agentHealth && agentHealth.last_heartbeat_error;
const errorAt = agentHealth && agentHealth.last_heartbeat_error_at;
const errorText = agentHealth
? (lastError
? `${failures ? "Последняя ошибка" : "Последний восстановленный сбой"}: ${humanizeAgentError(lastError)} · ${formatRelativeTime(errorAt)}`
: "Ошибок обмена не зафиксировано")
: "Расширенная диагностика недоступна в данных последнего heartbeat.";
els.agentHealthSummary.innerHTML = `
<div><span>Агент</span><strong>${escapeHtml(agentVersionLabel(version))}</strong></div>
<div><span>Runtime</span><strong>${escapeHtml(device.inventory && device.inventory.agent_runtime || "неизвестно")}</strong></div>
<div class="${failures ? "has-agent-error" : ""}"><span>Обмен с сервером</span><strong>${failures ? `${failures} ошибок подряд` : "Стабильно"}</strong></div>
<div class="${pending ? "has-agent-warning" : ""}"><span>Ожидают отправки</span><strong>${pending} ${resultWord(pending)}</strong></div>
<p class="agent-health-message">${escapeHtml(errorText)}</p>
`;
}
function resultWord(value) {
const number = Math.abs(Number(value || 0));
const lastTwo = number % 100;
if (lastTwo >= 11 && lastTwo <= 14) return "результатов";
if (number % 10 === 1) return "результат";
if (number % 10 >= 2 && number % 10 <= 4) return "результата";
return "результатов";
}
function renderHealthSummary(device) {
const checks = Array.isArray(device.metrics && device.metrics.connectivity_checks) ? device.metrics.connectivity_checks : [];
const serverHost = (device.metrics && device.metrics.server_check_target) || window.location.hostname;
const serverCheck = checks.find((check) => check.target === serverHost);
const wanChecks = checks.filter((check) => check.target !== serverHost);
const wanReachable = wanChecks.some((check) => check.reachable);
const serverStatus = serverCheck ? (serverCheck.reachable ? `Есть, ${serverCheck.latency_ms} ms` : "Нет") : (device.online ? "Есть" : "Нет данных");
const serverState = serverCheck ? (serverCheck.reachable ? "ok" : "bad") : (device.online ? "ok" : "warn");
const rows = [
["Статус", device.online ? "На связи" : "Нет связи", device.online ? "ok" : "bad"],
["Связь с сервером", serverStatus, serverState],
["Интернет/WAN", wanChecks.length ? (wanReachable ? "Есть" : "Нет") : "Нет данных", wanChecks.length && !wanReachable ? "bad" : "ok"],
["Активные проблемы", String(device.active_alerts || 0), device.active_alerts ? "warn" : "ok"],
["WAN IP", device.inventory && device.inventory.wan_ip ? device.inventory.wan_ip : "-", "neutral"],
];
els.healthSummary.innerHTML = "";
for (const [label, value, state] of rows) {
const row = document.createElement("div");
row.className = `health-item ${state}`;
row.innerHTML = `<span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong>`;
els.healthSummary.appendChild(row);
}
}
function kbToMb(value) {
return Math.round((Number(value || 0) / 1024) * 10) / 10;
}
function formatBytes(value) {
let amount = Number(value || 0);
const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
let unit = 0;
while (amount >= 1024 && unit < units.length - 1) {
amount /= 1024;
unit += 1;
}
return `${amount >= 10 || unit === 0 ? Math.round(amount) : amount.toFixed(1)} ${units[unit]}`;
}
function normalizedClients(device) {
if (device && device.id === state.selectedDeviceId && state.lanClients.length) {
return state.lanClients.map((client) => ({
name: client.hostname || "",
ip: client.ip || "-",
mac: client.mac || "-",
connection: client.connection === "wifi" ? `Wi-Fi ${client.interface || ""}`.trim() : `LAN ${client.interface || ""}`.trim(),
type: client.connection === "wifi" ? "wifi" : "wired",
online: client.status === "online",
presence: client.status || "unconfirmed",
lastSeenAt: client.last_seen_at || "",
}));
}
const leases = Array.isArray(device.inventory && device.inventory.dhcp_leases) ? device.inventory.dhcp_leases : [];
const wifi = Array.isArray(device.inventory && device.inventory.wifi_clients) ? device.inventory.wifi_clients : [];
const neighbors = Array.isArray(device.inventory && device.inventory.neighbors) ? device.inventory.neighbors : [];
const neighborsByMAC = new Map(neighbors.filter((neighbor) => neighbor.mac).map((neighbor) => [String(neighbor.mac).toLowerCase(), neighbor]));
const neighborsByIP = new Map(neighbors.filter((neighbor) => neighbor.ip).map((neighbor) => [String(neighbor.ip), neighbor]));
// Only states backed by recent neighbour discovery count as online.
// STALE, PERMANENT and NOARP can survive after a client disconnects.
const activeNeighborStates = new Set(["REACHABLE", "DELAY", "PROBE"]);
const byMac = new Map();
for (const lease of leases) {
const mac = String(lease.mac || "").toLowerCase();
const key = mac || `ip:${lease.ip || "unknown"}`;
const neighbor = neighborsByMAC.get(mac) || neighborsByIP.get(String(lease.ip || ""));
const neighborState = String(neighbor && neighbor.state || "").toUpperCase();
const presence = activeNeighborStates.has(neighborState) ? "online" : (neighborState === "STALE" ? "stale" : "reserved");
byMac.set(key, {
name: lease.hostname && lease.hostname !== "*" ? lease.hostname : "",
ip: lease.ip || "-",
mac: lease.mac || "-",
connection: presence === "online" ? `LAN ${neighbor && neighbor.interface || ""}`.trim() : "DHCP-запись",
type: "wired",
online: presence === "online",
presence,
});
}
for (const station of wifi) {
const mac = String(station.mac || "").toLowerCase();
const existing = byMac.get(mac) || {};
byMac.set(mac || `wifi:${station.interface || Math.random()}`, {
...existing,
name: existing.name || `Wi-Fi клиент ${station.mac || ""}`,
ip: existing.ip || "-",
mac: station.mac || existing.mac || "-",
connection: `Wi-Fi ${station.interface || ""}`.trim(),
signal: station.signal_dbm ? `${station.signal_dbm} dBm` : "",
rate: [station.rx_rate ? `RX ${station.rx_rate}` : "", station.tx_rate ? `TX ${station.tx_rate}` : ""].filter(Boolean).join(" / "),
type: "wifi",
online: true,
presence: "online",
});
}
const presenceRank = { online: 0, recent: 1, stale: 1, unconfirmed: 2, reserved: 2 };
return [...byMac.values()].sort((left, right) => {
const rank = (presenceRank[left.presence] ?? 3) - (presenceRank[right.presence] ?? 3);
if (rank !== 0) return rank;
return String(left.name || left.ip).localeCompare(String(right.name || right.ip), undefined, { numeric: true, sensitivity: "base" });
});
}
function renderClients(device) {
const clients = normalizedClients(device);
const search = els.clientSearch.value.trim().toLowerCase();
const filtered = clients.filter((client) => {
if (state.clientFilter === "online" && client.presence !== "online") return false;
if (state.clientFilter === "unconfirmed" && client.presence === "online") return false;
if (["wifi", "wired"].includes(state.clientFilter) && client.type !== state.clientFilter) return false;
return !search || [client.name, client.ip, client.mac, client.connection].join(" ").toLowerCase().includes(search);
});
els.clientList.innerHTML = "";
const onlineCount = clients.filter((client) => client.presence === "online").length;
const wifiOnline = clients.filter((client) => client.type === "wifi" && client.presence === "online").length;
els.clientSummary.textContent = `${onlineCount} в сети · ${wifiOnline} Wi-Fi · ${clients.length} известно`;
if (filtered.length === 0) {
els.clientList.innerHTML = inlineStateMarkup("Клиенты не найдены", "Проверьте фильтр или дождитесь обновления DHCP и Wi-Fi данных.");
return;
}
for (const client of filtered) {
const presence = client.presence || (client.online ? "online" : "reserved");
const presenceLabel = presence === "online" ? "В сети" : (presence === "recent" || presence === "stale" ? "Недавно был в сети" : "Не подтверждён");
const row = document.createElement("div");
row.className = "client-row";
row.innerHTML = `
<div class="client-name"><span class="client-icon">${client.type === "wifi" ? "⌁" : "▣"}</span><strong>${escapeHtml(client.name || client.ip || "Неизвестное устройство")}</strong></div>
<span>${escapeHtml(client.ip)}</span>
<code>${escapeHtml(client.mac)}</code>
<span>${escapeHtml(client.connection)}</span>
<span>${escapeHtml([client.signal, client.rate, client.lastSeenAt ? `был ${formatDate(client.lastSeenAt)}` : ""].filter(Boolean).join(" · ") || "-")}</span>
<span class="client-online ${presence}"><i></i>${presenceLabel}</span>
`;
els.clientList.appendChild(row);
}
}
function renderInterfaceCounters(device) {
const counters = Array.isArray(device.metrics && device.metrics.interface_counters) ? device.metrics.interface_counters : [];
const addresses = Array.isArray(device.inventory && device.inventory.interfaces) ? device.inventory.interfaces : [];
const byName = new Map();
for (const address of addresses) {
if (!byName.has(address.name)) byName.set(address.name, []);
byName.get(address.name).push(address.address);
}
els.interfaceCounters.innerHTML = "";
els.networkSummary.textContent = `${counters.length} интерфейсов`;
els.networkHealth.innerHTML = `
<div><span>WAN-адрес</span><strong>${escapeHtml(device.inventory && device.inventory.wan_ip ? device.inventory.wan_ip : "Нет данных")}</strong></div>
<div><span>Маршрут по умолчанию</span><strong>${escapeHtml(device.inventory && device.inventory.default_route ? device.inventory.default_route : "Нет данных")}</strong></div>
<div><span>Проверки связи</span><strong>${escapeHtml(formatConnectivity(device.metrics && device.metrics.connectivity_checks))}</strong></div>
`;
if (counters.length === 0) {
els.interfaceCounters.innerHTML = inlineStateMarkup("Нет данных об интерфейсах", "Агент еще не прислал сетевые счетчики или интерфейсы скрыты на стороне роутера.");
return;
}
for (const item of counters) {
const row = document.createElement("div");
const errors = Number(item.rx_errors || 0) + Number(item.tx_errors || 0);
row.className = "network-row";
row.innerHTML = `
<strong>${escapeHtml(item.name || "-")}</strong>
<span>${escapeHtml((byName.get(item.name) || []).join(", ") || "-")}</span>
<span>${escapeHtml(formatBytes(item.rx_bytes))}<small>${escapeHtml(item.rx_packets || 0)} пакетов</small></span>
<span>${escapeHtml(formatBytes(item.tx_bytes))}<small>${escapeHtml(item.tx_packets || 0)} пакетов</small></span>
<span class="${errors ? "interface-errors" : ""}">${escapeHtml(errors)}</span>
`;
els.interfaceCounters.appendChild(row);
}
}
async function loadMetricsHistory() {
if (!state.selectedDeviceId) return;
const data = await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/metrics-history?limit=48`);
renderMetricsHistory(data.samples || []);
}
function renderMetricsHistory(samples) {
els.metricsHistory.innerHTML = "";
if (samples.length === 0) {
els.metricsHistorySummary.textContent = "Нет замеров";
els.metricsHistory.innerHTML = inlineStateMarkup("История мониторинга пока пуста", "Графики появятся после нескольких heartbeat от агента.");
return;
}
const ordered = [...samples].reverse();
const historyRange = ordered.length > 1
? `${formatRelativeTime(ordered[0].created_at)} — сейчас`
: "первый замер";
els.metricsHistorySummary.textContent = `${samples.length} точек · ${historyRange}`;
const point = (sample, value) => ({ value: Number.isFinite(Number(value)) ? Number(value) : 0, time: sample.created_at });
const memoryPoints = ordered.map((sample) => {
const memory = sample.metrics && sample.metrics.memory;
return point(sample, memory && memory.total_kb ? Math.round((Number(memory.used_kb || 0) / Number(memory.total_kb)) * 100) : 0);
});
const diskPoints = ordered.map((sample) => {
const disk = sample.metrics && sample.metrics.disk;
return point(sample, disk && disk.used_percent ? Number(String(disk.used_percent).replace("%", "")) : 0);
});
const latencyPoints = ordered.map((sample) => {
const checks = Array.isArray(sample.metrics && sample.metrics.connectivity_checks) ? sample.metrics.connectivity_checks : [];
return point(sample, Math.max(0, ...checks.filter((check) => check.reachable).map((check) => Number(check.latency_ms || 0))));
});
const loadPoints = ordered.map((sample) => point(sample, Number.parseFloat(String(sample.metrics && sample.metrics.loadavg || "0")) || 0));
els.metricsHistory.appendChild(metricChart("Память", "%", memoryPoints, "warn", 100));
els.metricsHistory.appendChild(metricChart("Хранилище", "%", diskPoints, "good", 100));
els.metricsHistory.appendChild(metricChart("Задержка", "ms", latencyPoints, "accent"));
els.metricsHistory.appendChild(metricChart("Нагрузка", "", loadPoints, "violet"));
}
function metricChart(label, unit, points, tone, fixedMax = 0) {
const values = points.map((point) => Number(point.value || 0));
const max = fixedMax || Math.max(1, ...values);
const min = Math.min(...values);
const width = 320;
const height = 92;
const coordinates = values.map((value, index) => {
const x = values.length <= 1 ? width / 2 : (index / (values.length - 1)) * width;
const y = height - Math.max(2, Math.min(height - 2, (value / max) * (height - 8) + 4));
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const linePoints = coordinates.join(" ");
const areaPoints = values.length ? `0,${height} ${linePoints} ${width},${height}` : "";
const card = document.createElement("div");
card.className = `metric-chart ${tone}`;
const latest = values.length ? values[values.length - 1] : 0;
const formatValue = (value) => Number(value).toLocaleString(undefined, { maximumFractionDigits: unit === "%" ? 0 : 2 });
const startTime = points.length ? formatShortDate(points[0].time) : "-";
const endTime = points.length ? formatShortDate(points[points.length - 1].time) : "-";
card.innerHTML = `
<div class="metric-chart-title"><span>${escapeHtml(label)}</span><strong>${escapeHtml(formatValue(latest))}${unit ? ` ${escapeHtml(unit)}` : ""}</strong></div>
<div class="metric-chart-meta"><span>мин. ${escapeHtml(formatValue(min))}</span><span>макс. ${escapeHtml(formatValue(Math.max(...values)))}</span></div>
<div class="metric-line-chart">
<svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${escapeHtml(label)}: последнее значение ${escapeHtml(formatValue(latest))} ${escapeHtml(unit)}">
<line x1="0" y1="${height / 2}" x2="${width}" y2="${height / 2}"></line>
<polygon points="${areaPoints}"></polygon>
<polyline points="${linePoints}"></polyline>
</svg>
</div>
<div class="metric-chart-axis"><span>${escapeHtml(startTime)}</span><span>${escapeHtml(endTime)}</span></div>
`;
return card;
}
async function loadAlerts() {
if (!state.selectedDeviceId) return;
const status = encodeURIComponent(state.alertStatusFilter || "open");
const data = await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/alerts?status=${status}`);
state.alerts = data.alerts || [];
renderAlerts(state.alerts);
}
function renderAlerts(alerts) {
els.alertList.innerHTML = "";
const activeCount = alerts.filter((alert) => alert.status === "active").length;
const acknowledgedCount = alerts.filter((alert) => alert.status === "acknowledged").length;
const resolvedCount = alerts.filter((alert) => alert.status === "resolved").length;
els.alertSummary.textContent = `${activeCount} active / ${acknowledgedCount} ack / ${resolvedCount} resolved`;
if (alerts.length === 0) {
els.alertList.innerHTML = state.alertStatusFilter === "resolved"
? inlineStateMarkup("Resolved alerts не найдены", "Когда проблемы будут закрываться автоматически или вручную, они появятся здесь.")
: inlineStateMarkup("Активных алертов нет", "Сейчас устройство не требует внимания по правилам мониторинга.", "success");
return;
}
for (const alert of alerts) {
const details = alert.details ? Object.entries(alert.details).map(([key, value]) => `${key}: ${value}`).join(" / ") : "";
const actionButtons = alert.status === "resolved"
? `<button type="button" data-action="commands">Команды</button>`
: `<button type="button" data-action="diagnose">Диагностика</button>
<button type="button" data-action="commands">Команды</button>
<button type="button" data-action="ack" ${alert.status === "active" ? "" : "disabled"}>Ack</button>`;
const row = document.createElement("div");
row.className = `mini-row alert-row ${alert.severity || "warning"} ${alert.status || "active"}`;
row.innerHTML = `
<strong>${escapeHtml(alertTypeLabel(alert.type))}</strong>
<span>${escapeHtml(`${alert.severity || "-"} / ${alert.status || "-"}`)}</span>
<details class="alert-detail">
<summary>${escapeHtml(details || "Подробности")}</summary>
<div>Первый раз: ${escapeHtml(formatDate(alert.first_seen_at || alert.created_at))}</div>
<div>Последний раз: ${escapeHtml(formatDate(alert.last_seen_at || alert.created_at))}</div>
${alert.resolved_at ? `<div>Resolved: ${escapeHtml(formatDate(alert.resolved_at))}</div>` : ""}
<div>${escapeHtml(alert.message || "")}</div>
</details>
${actionButtons}
`;
row.querySelector('[data-action="diagnose"]')?.addEventListener("click", () => runAlertDiagnostics(alert));
row.querySelector('[data-action="commands"]').addEventListener("click", scrollToCommands);
row.querySelector('[data-action="ack"]')?.addEventListener("click", () => acknowledgeAlert(alert.id));
els.alertList.appendChild(row);
}
}
async function loadDevices() {
setStatus("Обновление");
await loadReleaseMetadata();
const data = await api("/api/devices");
state.devices = data.devices || [];
if (state.selectedDeviceId && !currentDevice()) {
state.selectedDeviceId = null;
}
renderDevices();
renderDeviceDetail(currentDevice());
if (state.selectedDeviceId) {
await Promise.all([loadCommands(), loadAudit(), loadMetricsHistory(), loadAlerts(), loadRemoteSessions(), loadLANClients()]);
}
state.lastUpdatedAt = new Date();
updateLiveStateLabel();
setStatus(`Обновлено ${state.lastUpdatedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`);
}
async function selectDevice(id) {
state.selectedDeviceId = id;
state.deviceTab = "overview";
state.presetReview = null;
state.commandOffset = 0;
state.auditOffset = 0;
state.commands = [];
state.auditEvents = [];
state.lanClients = [];
renderDevices();
renderDeviceDetail(currentDevice());
setMobileRoute("fleet");
window.scrollTo({ top: 0, behavior: "auto" });
await Promise.all([loadCommands(), loadAudit(), loadMetricsHistory(), loadAlerts(), loadRemoteSessions(), loadLANClients()]);
}
async function loadLANClients() {
if (!state.selectedDeviceId) return;
const response = await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/clients`);
state.lanClients = response.clients || [];
renderClients(currentDevice());
}
function showFleet() {
state.selectedDeviceId = null;
state.selectedCommand = null;
state.presetReview = null;
state.commandOffset = 0;
state.auditOffset = 0;
state.commands = [];
state.auditEvents = [];
renderDevices();
renderDeviceDetail(null);
setMobileRoute("fleet");
window.scrollTo({ top: 0, behavior: "auto" });
}
function selectDeviceTab(tab) {
state.deviceTab = tab;
renderDeviceTab();
if (tab === "operations") setMobileRoute("operations");
}
function renderDeviceTab() {
for (const button of document.querySelectorAll(".device-tab")) {
const active = button.dataset.deviceTabTarget === state.deviceTab;
button.classList.toggle("is-active", active);
button.setAttribute("aria-selected", String(active));
button.tabIndex = active ? 0 : -1;
}
for (const section of document.querySelectorAll("[data-device-tab]")) {
const active = section.dataset.deviceTab === state.deviceTab;
section.classList.toggle("is-hidden", !active);
section.hidden = !active;
section.setAttribute("aria-hidden", String(!active));
}
}
async function openLuCIAccess(session) {
const popup = window.open("", "_blank");
try {
const data = await api(`/api/devices/${encodeURIComponent(session.device_id)}/remote-sessions/${encodeURIComponent(session.id)}/access`, {
method: "POST",
});
if (popup) {
popup.opener = null;
popup.location = data.url;
} else {
window.location.assign(data.url);
}
} catch (error) {
if (popup) popup.close();
showLuCIState(error, "retry");
}
}
function openLuciOrRemoteAccess() {
openCloudAccess().catch(reportError);
}
function luciStateCopy(status, mode, error = {}) {
if (mode === "setup") {
return {
code: "409 · SESSION REQUIRED",
title: "Удалённый доступ не запущен",
description: "Создайте временную сессию, чтобы безопасно открыть LuCI этого роутера.",
action: "Настроить доступ",
};
}
if (error.code === "not_configured") {
return { code: "409 · CLOUD NOT CONFIGURED", title: "Облачный доступ не настроен", description: "Администратору нужно указать домен роутеров и публичный адрес tunnel-сервиса.", action: "Открыть настройки" };
}
if (error.code === "restart_failed" || error.code === "create_failed") {
return { code: "500 · TUNNEL START FAILED", title: "Не удалось запустить туннель", description: "Сервер не смог создать новый защищённый канал. Повторите попытку или откройте диагностику.", action: "Повторить подключение" };
}
return {
401: { code: "401 · SESSION EXPIRED", title: "Срок доступа истёк", description: "Временная ссылка больше не действует. Создайте новый безопасный доступ из RMM.", action: "Создать новый доступ" },
403: { code: "403 · ACCESS DENIED", title: "Доступ отклонён", description: "У вашей учётной записи нет разрешения на эту сессию LuCI.", action: "Вернуться к настройке" },
404: { code: "404 · SESSION NOT FOUND", title: "Сессия больше не активна", description: "Удалённая сессия закрыта или роутер ещё не подтвердил её запуск.", action: "Настроить доступ" },
409: { code: "409 · SESSION STARTING", title: "Туннель ещё запускается", description: "Роутер получил команду, но защищённый канал пока не готов. Обычно это занимает несколько секунд.", action: "Повторить подключение" },
429: { code: "429 · TOO MANY ATTEMPTS", title: "Слишком много запросов", description: "Лимит временных ссылок достигнут. Подождите минуту и повторите попытку.", action: "Повторить позже" },
502: { code: "502 · LUCI UNREACHABLE", title: "LuCI недоступен", description: "Туннель работает, но веб-интерфейс роутера не отвечает. Настройки роутера не изменялись.", action: "Повторить подключение" },
503: error.code === "device_offline"
? { code: "503 · ROUTER OFFLINE", title: "Роутер не на связи", description: "Агент не отправляет данные. Облачный доступ станет доступен после восстановления связи.", action: "Проверить снова" }
: { code: "503 · TUNNEL UNAVAILABLE", title: "Облачный канал недоступен", description: "Роутер на связи, но туннель до LuCI не открылся. Проверьте tunnel-сервис и журнал агента.", action: "Повторить подключение" },
504: { code: "504 · GATEWAY TIMEOUT", title: "LuCI не отвечает", description: "Роутер на связи, но веб-интерфейс не ответил вовремя. Настройки роутера не изменялись.", action: "Повторить подключение" },
}[status] || { code: `${status || 500} · CONNECTION ERROR`, title: "Не удалось открыть LuCI", description: "Проверьте состояние роутера и удалённой сессии, затем повторите попытку.", action: "Повторить подключение" };
}
function showLuCIState(error = {}, mode = "retry") {
const status = Number(error.status || 0);
const copy = luciStateCopy(status, mode, error);
const device = currentDevice();
const agentOnline = error.code === "device_offline" ? false : Boolean(device && device.online);
const activeSession = state.remoteSessions.find((session) => session.status === "active" && session.luci_port);
state.luciAction = mode;
els.luciStateCode.textContent = copy.code;
els.luciStateTitle.textContent = copy.title;
els.luciStateDescription.textContent = copy.description;
els.luciStatePrimaryBtn.textContent = copy.action;
els.luciStateContext.innerHTML = `
<div><span>Объект</span><strong>${escapeHtml(deviceDisplayName(device))}</strong></div>
<div><span>RMM-агент</span><strong>${agentOnline ? "На связи" : "Не на связи"}</strong></div>
<div><span>Сессия</span><strong>${activeSession ? escapeHtml(statusLabel(activeSession.status)) : "Не запущена"}</strong></div>
`;
els.luciStateRequestId.textContent = error.requestId ? `ID запроса: ${error.requestId}` : "";
if (!els.luciStateDialog.open) els.luciStateDialog.showModal();
}
function setMobileRoute(route) {
state.mobileRoute = route;
for (const button of document.querySelectorAll(".mobile-nav-item")) {
button.classList.toggle("is-active", button.dataset.mobileRoute === route);
}
els.fleetNavBtn.classList.toggle("is-active", route === "fleet");
els.problemsNavBtn.classList.toggle("is-active", route === "problems");
els.operationsNavBtn.classList.toggle("is-active", route === "operations");
}
async function openDeviceArea(tab, selector, route) {
let device = currentDevice();
if (!device) {
device = route === "problems"
? state.devices.find((item) => Number(item.active_alerts || 0) > 0) || state.devices[0]
: state.devices[0];
}
if (!device) {
notify("Сначала добавьте роутер", "info");
return;
}
if (device.id !== state.selectedDeviceId) await selectDevice(device.id);
selectDeviceTab(tab);
setMobileRoute(route);
window.requestAnimationFrame(() => document.querySelector(selector)?.scrollIntoView({ behavior: "smooth", block: "start" }));
}
function showProfile() {
state.previousMobileRoute = state.mobileRoute === "profile" ? "fleet" : state.mobileRoute;
setMobileRoute("profile");
els.profileDisplayName.value = state.user && state.user.display_name ? state.user.display_name : "";
els.profileEmail.value = state.user && state.user.email ? state.user.email : "";
setFormMessage(els.profileMessage, "");
setFormMessage(els.passwordMessage, "");
els.passwordForm.reset();
if (!els.profileDialog.open) els.profileDialog.showModal();
selectProfileTab("account");
}
function selectProfileTab(requestedTab) {
const isAdmin = state.user && state.user.role === "admin";
const tab = requestedTab === "admin" && !isAdmin ? "account" : requestedTab;
state.profileTab = tab;
for (const button of els.profileTabs) {
const selected = button.dataset.profileTab === tab;
button.classList.toggle("is-active", selected);
button.setAttribute("aria-selected", String(selected));
button.tabIndex = selected ? 0 : -1;
}
for (const panel of els.profilePanels) {
panel.classList.toggle("is-hidden", panel.dataset.profilePanel !== tab);
}
if (tab === "notifications") {
loadNotificationPreferences().catch((error) => {
setFormMessage(els.notificationSettingsMessage, `Не удалось загрузить уведомления: ${error.message}`, "error");
});
}
if (tab === "admin" && isAdmin) {
loadUsers().catch((error) => {
els.userList.innerHTML = inlineStateMarkup("Не удалось загрузить пользователей", error.message);
});
loadRollouts().catch((error) => { els.rolloutList.innerHTML = inlineStateMarkup("Не удалось загрузить rollout", error.message); });
}
}
async function loadRollouts() {
const response = await api("/api/agent-rollouts");
els.rolloutChannel.querySelector('option[value="candidate"]').disabled = !response.candidate_available;
els.rolloutChannel.title = response.candidate_available ? "" : "Candidate manifest is unavailable";
els.rolloutList.innerHTML = (response.rollouts || []).map((rollout) => `<article class="user-row"><div><strong>${escapeHtml(rollout.channel)} ${escapeHtml(rollout.target_version)}</strong><small>${escapeHtml(rollout.status)}: ${rollout.devices.length} устройств, ошибок ${rollout.failure_count}</small></div><div class="user-actions">${rollout.status === "running" ? `<button data-rollout-action="pause" data-rollout-id="${escapeHtml(rollout.id)}" type="button">Пауза</button>` : ""}${rollout.status === "paused" ? `<button data-rollout-action="resume" data-rollout-id="${escapeHtml(rollout.id)}" type="button">Продолжить</button>` : ""}${["running", "paused"].includes(rollout.status) ? `<button class="danger" data-rollout-action="cancel" data-rollout-id="${escapeHtml(rollout.id)}" type="button">Отменить</button>` : ""}</div></article>`).join("") || inlineStateMarkup("Нет rollout", "Создайте первый rollout для явного списка устройств.");
}
els.rolloutForm.addEventListener("submit", (event) => {
event.preventDefault();
const device_ids = els.rolloutDeviceIds.value.split(",").map((value) => value.trim()).filter(Boolean);
api("/api/agent-rollouts", { method: "POST", body: JSON.stringify({ device_ids, channel: els.rolloutChannel.value, batch_size: Number(els.rolloutBatchSize.value), failure_threshold: Number(els.rolloutFailureThreshold.value) }) }).then(() => { setFormMessage(els.rolloutMessage, "Rollout создан", "success"); return loadRollouts(); }).catch((error) => setFormMessage(els.rolloutMessage, error.message, "error"));
});
els.rolloutList.addEventListener("click", (event) => {
const button = event.target.closest("[data-rollout-action]"); if (!button) return;
api(`/api/agent-rollouts/${encodeURIComponent(button.dataset.rolloutId)}/${button.dataset.rolloutAction}`, { method: "POST" }).then(loadRollouts).catch((error) => setFormMessage(els.rolloutMessage, error.message, "error"));
});
function closeProfile() {
if (els.profileDialog.open) els.profileDialog.close();
setMobileRoute(state.previousMobileRoute || "fleet");
}
function setFormMessage(element, message, tone = "") {
element.textContent = message;
element.className = `form-message${tone ? ` is-${tone}` : ""}`;
}
async function saveProfile() {
setFormMessage(els.profileMessage, "Сохраняем…");
const response = await api("/api/auth/profile", {
method: "PATCH",
body: JSON.stringify({
display_name: els.profileDisplayName.value.trim(),
email: els.profileEmail.value.trim(),
}),
});
showApp(response.user);
await loadNotificationPreferences();
setFormMessage(els.profileMessage, "Профиль сохранён", "success");
}
async function loadNotificationPreferences() {
setFormMessage(els.notificationSettingsMessage, "Загружаем настройки…");
const [settingsResponse, historyResponse] = await Promise.all([
api("/api/notifications/settings"),
fetchNotificationHistory(),
]);
state.notificationSettings = settingsResponse.settings || {};
state.notificationChannels = settingsResponse.channels || {};
state.notifications = historyResponse.notifications || [];
state.notificationMetrics = historyResponse.metrics || {};
renderNotificationSettings();
renderNotificationDeviceFilters();
renderNotificationMetrics();
renderNotificationChannelDiagnostics();
renderNotificationHistory();
setFormMessage(els.notificationSettingsMessage, state.notificationSettings.configured ? "Настройки загружены" : "Каналы пока выключены");
}
function renderNotificationSettings() {
const settings = state.notificationSettings || {};
const email = state.notificationChannels.email || {};
const telegram = state.notificationChannels.telegram || {};
els.notificationEmailEnabled.checked = Boolean(settings.email_enabled);
els.notificationEmailEnabled.disabled = !email.available || !email.profile_email_configured;
els.verifyEmailBtn.disabled = !email.available || !email.profile_email_configured;
els.confirmEmailBtn.disabled = !email.available || !email.profile_email_configured;
els.notificationEmailHint.textContent = !email.available
? "SMTP не настроен на сервере"
: !email.profile_email_configured
? "Сначала добавьте e-mail в профиль"
: `${email.verified ? "Подтверждён" : "Требуется подтверждение"} · ${email.destination || "e-mail профиля"}`;
els.notificationTelegramEnabled.checked = Boolean(settings.telegram_enabled);
els.notificationTelegramEnabled.disabled = !telegram.available;
els.verifyTelegramBtn.disabled = !telegram.available;
els.confirmTelegramBtn.disabled = !telegram.available;
els.notificationTelegramHint.textContent = telegram.available ? (telegram.verified ? "Telegram подтверждён" : "Подтвердите кодом из сообщения") : "Bot token не настроен на сервере";
els.notificationTelegramChatId.value = settings.telegram_chat_id || "";
els.notificationTelegramChatId.disabled = !telegram.available;
els.notificationTelegramChatRow.classList.toggle("is-disabled", !telegram.available);
els.notificationWarningEnabled.checked = settings.notify_warning !== false;
els.notificationCriticalEnabled.checked = settings.notify_critical !== false;
els.notificationResolvedEnabled.checked = settings.notify_resolved !== false;
els.notificationMemoryThreshold.value = settings.memory_threshold_percent || 85;
els.notificationDiskThreshold.value = settings.disk_threshold_percent || 85;
els.notificationPacketLossThreshold.value = settings.packet_loss_percent || 20;
els.notificationLatencyThreshold.value = settings.latency_threshold_ms || 200;
els.notificationRepeatMinutes.value = String(settings.repeat_minutes || 0);
els.notificationTimezone.value = settings.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
els.notificationPausedUntil.value = dateTimeLocalValue(settings.alerts_paused_until);
els.notificationQuietEnabled.checked = Boolean(settings.quiet_hours_enabled);
els.notificationQuietStart.value = settings.quiet_hours_start || "22:00";
els.notificationQuietEnd.value = settings.quiet_hours_end || "08:00";
els.notificationWebhookEnabled.checked = Boolean(settings.webhook_enabled);
els.notificationWebhookUrl.value = settings.webhook_url || "";
els.notificationWebhookSecret.value = "";
els.notificationWebhookSecret.placeholder = settings.webhook_secret_configured ? "Секрет уже настроен" : "Не менее 32 символов";
renderNotificationDeviceOptions();
}
async function saveNotificationSettings() {
setFormMessage(els.notificationSettingsMessage, "Сохраняем…");
const response = await api("/api/notifications/settings", {
method: "PUT",
body: JSON.stringify({
email_enabled: els.notificationEmailEnabled.checked,
telegram_enabled: els.notificationTelegramEnabled.checked,
telegram_chat_id: els.notificationTelegramChatId.value.trim(),
notify_warning: els.notificationWarningEnabled.checked,
notify_critical: els.notificationCriticalEnabled.checked,
notify_resolved: els.notificationResolvedEnabled.checked,
memory_threshold_percent: Number(els.notificationMemoryThreshold.value),
disk_threshold_percent: Number(els.notificationDiskThreshold.value),
packet_loss_percent: Number(els.notificationPacketLossThreshold.value),
latency_threshold_ms: Number(els.notificationLatencyThreshold.value),
repeat_minutes: Number(els.notificationRepeatMinutes.value),
timezone: els.notificationTimezone.value.trim() || "UTC",
alerts_paused_until: isoFromDateTimeLocal(els.notificationPausedUntil.value),
quiet_hours_enabled: els.notificationQuietEnabled.checked,
quiet_hours_start: els.notificationQuietStart.value || "22:00",
quiet_hours_end: els.notificationQuietEnd.value || "08:00",
webhook_enabled: els.notificationWebhookEnabled.checked,
webhook_url: els.notificationWebhookUrl.value.trim(),
webhook_secret: els.notificationWebhookSecret.value,
}),
});
state.notificationSettings = response.settings || {};
state.notificationChannels = response.channels || {};
renderNotificationSettings();
setFormMessage(els.notificationSettingsMessage, "Настройки уведомлений сохранены", "success");
}
async function testNotifications() {
els.testNotificationsBtn.disabled = true;
setFormMessage(els.notificationSettingsMessage, "Отправляем тест…");
try {
const response = await api("/api/notifications/test", { method: "POST" });
const deliveries = response.notifications || [];
const sent = deliveries.filter((item) => item.status === "sent").length;
const failed = deliveries.length - sent;
await loadNotificationHistory();
setFormMessage(
els.notificationSettingsMessage,
failed ? `Отправлено: ${sent}, с ошибкой: ${failed}` : `Тест отправлен по каналам: ${sent}`,
failed ? "error" : "success",
);
} finally {
els.testNotificationsBtn.disabled = false;
}
}
async function loadNotificationHistory() {
const response = await fetchNotificationHistory();
state.notifications = response.notifications || [];
state.notificationMetrics = response.metrics || {};
renderNotificationMetrics();
renderNotificationChannelDiagnostics();
renderNotificationHistory();
}
function fetchNotificationHistory() {
const query = new URLSearchParams({ limit: "100" });
const filters = [
["device_id", els.notificationFilterDevice.value],
["severity", els.notificationFilterSeverity.value],
["event", els.notificationFilterEvent.value],
["channel", els.notificationFilterChannel.value],
["status", els.notificationFilterStatus.value],
];
for (const [name, value] of filters) {
if (value) query.set(name, value);
}
return api(`/api/notifications?${query.toString()}`);
}
function renderNotificationDeviceFilters() {
const current = els.notificationFilterDevice.value;
els.notificationFilterDevice.innerHTML = '<option value="">Все роутеры</option>';
for (const device of state.devices) {
const option = document.createElement("option");
option.value = device.id;
option.textContent = deviceDisplayName(device);
els.notificationFilterDevice.appendChild(option);
}
if (state.devices.some((device) => device.id === current)) {
els.notificationFilterDevice.value = current;
}
}
function renderNotificationMetrics() {
const metrics = state.notificationMetrics || {};
const queueAge = Number(metrics.oldest_queue_age_seconds || 0);
const cards = [
["В очереди", Number(metrics.queued || 0), queueAge > 0 ? `старейшая ${formatDurationSeconds(queueAge)}` : "очередь пуста", "queued"],
["Доставлено", Number(metrics.sent || 0), "за период хранения", "sent"],
["С ошибкой", Number(metrics.failed || 0), "ожидают повтора", "failed"],
["Dead letter", Number(metrics.dead_letter || 0), "требуют внимания", "dead-letter"],
];
els.notificationMetrics.innerHTML = cards.map(([label, value, note, tone]) => `
<article class="notification-metric is-${tone}">
<span>${escapeHtml(label)}</span>
<strong>${value}</strong>
<small>${escapeHtml(note)}</small>
</article>
`).join("");
}
function formatDurationSeconds(value) {
const seconds = Math.max(0, Number(value || 0));
if (seconds < 60) return `${Math.round(seconds)} сек`;
if (seconds < 3600) return `${Math.round(seconds / 60)} мин`;
if (seconds < 86400) return `${Math.round(seconds / 3600)} ч`;
return `${Math.round(seconds / 86400)} дн`;
}
function renderNotificationChannelDiagnostics() {
const channelNames = { email: "E-mail / SMTP", telegram: "Telegram", webhook: "Webhook" };
const latestMetrics = new Map((state.notificationMetrics.channels || []).map((item) => [item.channel, item]));
els.notificationChannelDiagnostics.innerHTML = Object.entries(channelNames).map(([channel, name]) => {
const diagnostic = state.notificationChannels[channel] || {};
const delivery = latestMetrics.get(channel) || diagnostic.delivery || {};
const details = [];
if (delivery.last_success_at) details.push(`Последняя доставка: ${formatDate(delivery.last_success_at)}`);
if (delivery.last_error_at) details.push(`Последняя ошибка: ${formatDate(delivery.last_error_at)}`);
if (delivery.last_error) details.push(delivery.last_error);
return `
<article class="notification-channel-diagnostic is-${escapeHtml(diagnostic.status || "disabled")}">
<div><strong>${escapeHtml(name)}</strong><span>${escapeHtml(diagnostic.message || "Состояние неизвестно")}</span></div>
<small>${escapeHtml(details.join(" · ") || "Истории доставки пока нет")}</small>
</article>
`;
}).join("");
}
function renderNotificationHistory() {
els.notificationHistory.innerHTML = "";
if (!state.notifications.length) {
els.notificationHistory.innerHTML = inlineStateMarkup("Отправок пока нет", "После первой проблемы или теста здесь появится результат доставки.");
return;
}
for (const delivery of state.notifications) {
const row = document.createElement("article");
row.className = `notification-history-row is-${delivery.status || "queued"}`;
const attempts = delivery.attempt_count > 0 ? ` · попытка ${delivery.attempt_count}/${delivery.max_attempts || "?"}` : "";
const retryAt = delivery.status === "retry" && delivery.next_attempt_at
? ` · повтор ${formatDate(delivery.next_attempt_at)}`
: "";
const device = state.devices.find((item) => item.id === delivery.device_id);
const channelLabel = { email: "E-mail", telegram: "Telegram", webhook: "Webhook" }[delivery.channel] || delivery.channel;
const context = [
device ? deviceDisplayName(device) : "",
delivery.severity === "critical" ? "Критическое" : delivery.severity === "warning" ? "Предупреждение" : "",
notificationEventLabel(delivery.event),
].filter(Boolean).join(" · ");
row.innerHTML = `
<span class="notification-channel">${escapeHtml(channelLabel)}</span>
<div><strong>${escapeHtml(delivery.title || "Уведомление")}</strong><small>${escapeHtml([context, delivery.destination || ""].filter(Boolean).join(" · "))}${delivery.error ? ` · ${escapeHtml(delivery.error)}` : ""}${escapeHtml(attempts)}${escapeHtml(retryAt)}</small></div>
<span class="notification-delivery-status">${escapeHtml(notificationStatusLabel(delivery.status))}</span>
<time>${escapeHtml(formatDate(delivery.sent_at || delivery.created_at))}</time>
`;
els.notificationHistory.appendChild(row);
}
}
function notificationEventLabel(event) {
return {
active: "Возникло",
repeat: "Повтор",
resolved: "Восстановлено",
test: "Тест",
}[event] || event || "";
}
function dateTimeLocalValue(value) {
if (!value) return "";
const date = new Date(value);
const offset = date.getTimezoneOffset() * 60000;
return new Date(date.getTime() - offset).toISOString().slice(0, 16);
}
async function requestContactVerification(channel) {
const destination = channel === "telegram" ? els.notificationTelegramChatId.value.trim() : "";
await api(`/api/notifications/verify/${channel}/request`, {
method: "POST",
body: JSON.stringify({ destination }),
});
setFormMessage(els.notificationSettingsMessage, `Код отправлен в ${channel === "email" ? "e-mail" : "Telegram"}`, "success");
}
async function confirmContactVerification(channel) {
const input = channel === "email" ? els.emailVerificationCode : els.telegramVerificationCode;
await api(`/api/notifications/verify/${channel}/confirm`, {
method: "POST",
body: JSON.stringify({ code: input.value.trim() }),
});
input.value = "";
await loadNotificationPreferences();
setFormMessage(els.notificationSettingsMessage, `${channel === "email" ? "E-mail" : "Telegram"} подтверждён`, "success");
}
function isoFromDateTimeLocal(value) {
return value ? new Date(value).toISOString() : "";
}
function renderNotificationDeviceOptions() {
const current = els.notificationDeviceSelect.value;
els.notificationDeviceSelect.innerHTML = '<option value="">Выберите роутер</option>';
for (const device of state.devices) {
const option = document.createElement("option");
option.value = device.id;
option.textContent = deviceDisplayName(device);
els.notificationDeviceSelect.appendChild(option);
}
if (state.devices.some((device) => device.id === current)) els.notificationDeviceSelect.value = current;
setDeviceNotificationControlsDisabled(!els.notificationDeviceSelect.value);
}
function setDeviceNotificationControlsDisabled(disabled) {
els.notificationDeviceEnabled.disabled = disabled;
els.notificationDeviceWarning.disabled = disabled;
els.notificationDeviceCritical.disabled = disabled;
els.notificationDeviceResolved.disabled = disabled;
els.notificationDevicePausedUntil.disabled = disabled;
els.saveDeviceNotificationSettingsBtn.disabled = disabled;
}
async function loadDeviceNotificationSettings() {
const deviceId = els.notificationDeviceSelect.value;
setDeviceNotificationControlsDisabled(!deviceId);
if (!deviceId) return;
const response = await api(`/api/devices/${encodeURIComponent(deviceId)}/notification-settings`);
const settings = response.settings || {};
els.notificationDeviceEnabled.checked = settings.enabled !== false;
els.notificationDeviceWarning.checked = settings.notify_warning !== false;
els.notificationDeviceCritical.checked = settings.notify_critical !== false;
els.notificationDeviceResolved.checked = settings.notify_resolved !== false;
els.notificationDevicePausedUntil.value = dateTimeLocalValue(settings.paused_until);
}
async function saveDeviceNotificationSettings() {
const deviceId = els.notificationDeviceSelect.value;
if (!deviceId) {
setFormMessage(els.notificationSettingsMessage, "Выберите роутер", "error");
return;
}
await api(`/api/devices/${encodeURIComponent(deviceId)}/notification-settings`, {
method: "PATCH",
body: JSON.stringify({
enabled: els.notificationDeviceEnabled.checked,
notify_warning: els.notificationDeviceWarning.checked,
notify_critical: els.notificationDeviceCritical.checked,
notify_resolved: els.notificationDeviceResolved.checked,
paused_until: isoFromDateTimeLocal(els.notificationDevicePausedUntil.value),
}),
});
setFormMessage(els.notificationSettingsMessage, "Настройки роутера сохранены", "success");
}
async function loadNotificationCenter() {
const response = await api("/api/notification-center?limit=50");
state.inboxNotifications = response.notifications || [];
state.notificationUnread = Number(response.unread || 0);
renderNotificationCenter();
}
function renderNotificationCenter() {
const unread = state.notificationUnread;
els.notificationUnreadCount.textContent = unread > 99 ? "99+" : String(unread);
els.notificationUnreadCount.classList.toggle("is-hidden", unread === 0);
els.mobileNotificationUnreadCount.textContent = unread > 99 ? "99+" : String(unread);
els.mobileNotificationUnreadCount.classList.toggle("is-hidden", unread === 0);
els.notificationCenterSummary.textContent = unread ? `${unread} непрочитанных` : "Новых событий нет";
els.notificationCenterList.innerHTML = "";
if (!state.inboxNotifications.length) {
els.notificationCenterList.innerHTML = inlineStateMarkup("Уведомлений пока нет", "Новые проблемы и восстановления появятся здесь.");
return;
}
const groups = new Map();
for (const item of state.inboxNotifications) {
const key = item.incident_id || item.id;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(item);
}
for (const items of groups.values()) {
const item = items[0];
const button = document.createElement("button");
button.type = "button";
button.className = `notification-center-item severity-${item.severity || "warning"} ${item.read_at ? "" : "is-unread"}`;
button.innerHTML = `
<span class="notification-center-dot"></span>
<span><strong>${escapeHtml(item.title)}</strong><small>${escapeHtml(items.length > 1 ? `${items.length} связанных событий · ${item.body}` : item.body)}</small></span>
<time>${escapeHtml(formatDate(item.created_at))}</time>
`;
button.addEventListener("click", async () => {
for (const current of items.filter((entry) => !entry.read_at)) {
await api(`/api/notification-center/${encodeURIComponent(current.id)}/read`, { method: "POST" });
}
if (item.device_id) {
els.notificationCenterDialog.close();
await selectDevice(item.device_id);
}
await loadNotificationCenter();
});
els.notificationCenterList.appendChild(button);
}
}
async function showNotificationCenter() {
await loadNotificationCenter();
if (!els.notificationCenterDialog.open) els.notificationCenterDialog.showModal();
}
async function markAllNotificationsRead() {
await api("/api/notification-center/read-all", { method: "POST" });
await loadNotificationCenter();
}
function notificationStatusLabel(status) {
return {
queued: "В очереди",
sending: "Отправляется",
retry: "Повтор ожидается",
sent: "Отправлено",
dead_letter: "Не доставлено",
failed: "Ошибка",
}[status] || status || "Неизвестно";
}
function notificationErrorMessage(error) {
const raw = String(error && error.message ? error.message : error || "Неизвестная ошибка");
const requestId = raw.match(/\(req_[^)]+\)$/)?.[0] || "";
const message = raw.replace(/\s*\(req_[^)]+\)$/, "");
const translations = {
"enable and save at least one notification channel": "Включите и сохраните хотя бы один доступный канал",
"no available notification channels": "На сервере нет доступных каналов уведомлений",
"email notifications are not configured on the server": "SMTP для уведомлений не настроен на сервере",
"add an email address to the profile before enabling email notifications": "Сначала добавьте e-mail в контактные данные профиля",
"Telegram notifications are not configured on the server": "Telegram-бот не настроен на сервере",
"Telegram chat ID is invalid": "Некорректный Telegram Chat ID",
"memory and disk thresholds must be between 50 and 99 percent": "Пороги памяти и диска должны быть от 50 до 99%",
"packet loss threshold must be between 1 and 100 percent": "Порог потерь должен быть от 1 до 100%",
"latency threshold must be between 10 and 5000 milliseconds": "Порог задержки должен быть от 10 до 5000 мс",
"repeat interval must be 0 or between 15 and 10080 minutes": "Интервал повтора должен быть выключен или находиться в пределах 15–10080 минут",
};
return `${translations[message] || message}${requestId ? ` ${requestId}` : ""}`;
}
async function changePassword() {
setFormMessage(els.passwordMessage, "");
if (els.newPassword.value !== els.confirmPassword.value) {
setFormMessage(els.passwordMessage, "Новые пароли не совпадают", "error");
return;
}
await api("/api/auth/change-password", {
method: "POST",
body: JSON.stringify({ current_password: els.currentPassword.value, new_password: els.newPassword.value }),
});
els.passwordForm.reset();
setFormMessage(els.passwordMessage, "Пароль изменён. Остальные сессии завершены.", "success");
}
async function logoutAll() {
if (!window.confirm("Завершить все активные сессии, включая эту?")) return;
await api("/api/auth/logout-all", { method: "POST" });
closeProfile();
state.devices = [];
state.user = null;
showLogin("Все сессии завершены. Войдите снова.");
}
async function loadUsers() {
const response = await api("/api/users");
state.users = response.users || [];
renderUsers();
}
function renderUsers() {
els.userList.innerHTML = "";
for (const user of state.users) {
const ownAccount = state.user && user.id === state.user.id;
const row = document.createElement("article");
row.className = `user-row${user.disabled ? " is-disabled" : ""}`;
row.innerHTML = `
<div class="user-row-identity">
<span class="profile-avatar">${escapeHtml((user.display_name || user.username || "U").charAt(0).toUpperCase())}</span>
<div><strong>${escapeHtml(user.display_name || user.username)}</strong><small>${escapeHtml(user.username)}${user.email ? ` · ${escapeHtml(user.email)}` : " · e-mail не задан"}</small></div>
</div>
<label>Роль<select data-user-role ${ownAccount ? "disabled" : ""}><option value="user" ${user.role === "user" ? "selected" : ""}>Пользователь</option><option value="admin" ${user.role === "admin" ? "selected" : ""}>Администратор</option></select></label>
<div class="user-row-actions">
<button type="button" data-user-password ${ownAccount ? "disabled" : ""}>Новый пароль</button>
<button type="button" data-user-disabled class="${user.disabled ? "" : "danger"}" ${ownAccount ? "disabled" : ""}>${user.disabled ? "Включить" : "Отключить"}</button>
</div>
`;
row.querySelector("[data-user-role]").addEventListener("change", (event) => updateManagedUser(user, { role: event.target.value }));
row.querySelector("[data-user-disabled]").addEventListener("click", () => updateManagedUser(user, { disabled: !user.disabled }));
row.querySelector("[data-user-password]").addEventListener("click", () => resetManagedUserPassword(user));
els.userList.appendChild(row);
}
}
async function updateManagedUser(user, changes) {
try {
await api(`/api/users/${encodeURIComponent(user.id)}`, { method: "PATCH", body: JSON.stringify(changes) });
await loadUsers();
notify(`Аккаунт ${user.username} обновлён`, "success");
} catch (error) {
await loadUsers();
reportError(error);
}
}
function resetManagedUserPassword(user) {
const password = window.prompt(`Новый временный пароль для ${user.username} (минимум 12 символов)`);
if (password === null) return;
updateManagedUser(user, { password }).catch(reportError);
}
async function requestPasswordReset() {
setFormMessage(els.forgotPasswordMessage, "Отправляем…");
await api("/api/auth/password-reset/request", {
method: "POST",
body: JSON.stringify({ identifier: els.passwordResetIdentifier.value.trim() }),
});
els.forgotPasswordForm.reset();
setFormMessage(els.forgotPasswordMessage, "Если e-mail восстановления настроен, ссылка уже отправлена.", "success");
}
async function confirmPasswordReset() {
setFormMessage(els.passwordResetMessage, "");
if (els.resetNewPassword.value !== els.resetConfirmPassword.value) {
setFormMessage(els.passwordResetMessage, "Пароли не совпадают", "error");
return;
}
await api("/api/auth/password-reset/confirm", {
method: "POST",
body: JSON.stringify({ token: state.passwordResetToken, new_password: els.resetNewPassword.value }),
});
state.passwordResetToken = "";
history.replaceState(null, "", `${location.pathname}${location.search}`);
els.passwordResetForm.reset();
setFormMessage(els.passwordResetMessage, "Пароль изменён. Теперь можно войти.", "success");
}
function openPasswordResetFromURL() {
const match = location.hash.match(/^#password-reset=(.+)$/);
if (!match) return;
try {
state.passwordResetToken = decodeURIComponent(match[1]);
} catch {
state.passwordResetToken = "";
}
if (state.passwordResetToken) els.passwordResetDialog.showModal();
}
async function runLuCIPrimaryAction() {
if (els.luciStateDialog.open) els.luciStateDialog.close();
if (state.luciAction === "retry") {
await openCloudAccess();
return;
}
await openDeviceArea("operations", "#remoteAccessPanel", "operations");
}
async function refreshDevicesIfIdle() {
if (refreshDevicesIfIdle.inFlight) return;
refreshDevicesIfIdle.inFlight = true;
try {
await loadDevices();
} finally {
refreshDevicesIfIdle.inFlight = false;
}
}
refreshDevicesIfIdle.inFlight = false;
async function loadCommands(options = {}) {
if (!state.selectedDeviceId) return;
const append = Boolean(options.append);
const offset = append ? state.commandOffset : 0;
const data = await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/commands?limit=${state.commandLimit}&offset=${offset}`);
const nextCommands = data.commands || [];
state.commands = append ? [...state.commands, ...nextCommands] : nextCommands;
state.commandOffset = offset + nextCommands.length;
state.commandHasMore = nextCommands.length === state.commandLimit;
renderCommands(state.commands);
renderCommandLoadMore();
renderPresetReview();
if (state.selectedCommand) {
const refreshed = state.commands.find((command) => command.id === state.selectedCommand.id);
state.selectedCommand = refreshed || null;
renderCommandDetail(state.selectedCommand);
}
}
function renderCommandLoadMore() {
els.loadMoreCommandsBtn.classList.toggle("is-hidden", !state.commandHasMore);
}
function renderCommands(commands) {
renderAgentUpdateStatus();
els.commandList.innerHTML = "";
renderCommandSummary(commands);
const filtered = commands.filter(commandFilterMatches);
if (filtered.length === 0) {
els.commandList.innerHTML = inlineStateMarkup("Команд по выбранному фильтру нет", "Измените фильтр или отправьте новую команду.");
return;
}
for (const command of filtered) {
const row = document.createElement("div");
row.className = "command-row";
const canCancel = command.status === "queued" || command.status === "claimed";
const output = command.output || JSON.stringify(command.args || {});
const finishedAt = command.completed_at || command.cancelled_at || command.expired_at;
row.innerHTML = `
<div class="command-main">
<strong>${escapeHtml(commandTypeLabel(command.type))}</strong>
<small>${escapeHtml(command.type)} · ${escapeHtml(command.id)}</small>
</div>
<span class="status ${escapeHtml(command.status)}">${escapeHtml(statusLabel(command.status))}</span>
<span>${command.attempt_count || 0}/${command.max_attempts || 3} попытка</span>
<span class="lifecycle-time">${escapeHtml(formatShortDate(finishedAt || command.claimed_at || command.created_at))}</span>
<details class="command-output">
<summary>${escapeHtml(outputSummary(output))}</summary>
<pre>${escapeHtml(output)}</pre>
</details>
<div class="row-actions">
<button type="button" data-action="detail">Детали</button>
<button type="button" data-action="cancel" ${canCancel ? "" : "disabled"}>Отменить</button>
</div>
`;
row.querySelector('[data-action="detail"]').addEventListener("click", () => {
state.selectedCommand = command;
renderCommandDetail(command);
});
row.querySelector('[data-action="cancel"]').addEventListener("click", () => cancelCommand(command.id));
els.commandList.appendChild(row);
}
}
function renderCommandSummary(commands) {
const counts = commands.reduce((acc, command) => {
acc[command.status] = (acc[command.status] || 0) + 1;
return acc;
}, {});
const total = commands.length;
const active = (counts.queued || 0) + (counts.claimed || 0);
const failed = (counts.failed || 0) + (counts.expired || 0);
els.commandSummary.textContent = `${total} всего / ${active} выполняются / ${failed} ошибки`;
}
function renderCommandDetail(command) {
if (!command) {
els.commandDetailPanel.classList.add("is-hidden");
return;
}
const output = command.output || JSON.stringify(command.args || {}, null, 2);
els.commandDetailPanel.classList.remove("is-hidden");
els.commandDetailMeta.innerHTML = `
<span>${escapeHtml(commandTypeLabel(command.type))}</span>
<span>${escapeHtml(statusLabel(command.status))}</span>
<span>${command.attempt_count || 0}/${command.max_attempts || 3} попытка</span>
<span>создана ${escapeHtml(formatShortDate(command.created_at))}</span>
<span>истекает ${escapeHtml(formatShortDate(command.expires_at))}</span>
<span>забрана ${escapeHtml(formatShortDate(command.claimed_at))}</span>
<span>завершена ${escapeHtml(formatShortDate(command.completed_at || command.cancelled_at || command.expired_at))}</span>
<span>${escapeHtml(command.id)}</span>
`;
els.commandDetailOutput.innerHTML = highlightOutput(output);
}
function highlightOutput(output) {
return escapeHtml(output).replace(/^(BACKUP|BEFORE|AFTER|CHANGE|DIFF|PREVIEW)(.*)$/gm, '<span class="output-section">$1$2</span>');
}
function outputSummary(output) {
const firstLine = String(output || "").split("\n")[0] || "-";
return firstLine.length > 90 ? `${firstLine.slice(0, 90)}...` : firstLine;
}
async function loadAudit(options = {}) {
if (!state.selectedDeviceId) return;
const append = Boolean(options.append);
const offset = append ? state.auditOffset : 0;
const data = await api(`/api/audit-events?device_id=${encodeURIComponent(state.selectedDeviceId)}&limit=${state.auditLimit}&offset=${offset}`);
const events = data.audit_events || [];
const allEvents = append ? [...state.auditEvents || [], ...events] : events;
state.auditEvents = allEvents;
state.auditOffset = offset + events.length;
state.auditHasMore = events.length === state.auditLimit;
renderAudit(allEvents);
renderAuditLoadMore();
}
function renderAuditLoadMore() {
els.loadMoreAuditBtn.classList.toggle("is-hidden", !state.auditHasMore);
}
function renderAudit(events) {
els.auditList.innerHTML = "";
els.auditSummary.textContent = `${events.length} events`;
if (events.length === 0) {
els.auditList.innerHTML = inlineStateMarkup("Событий аудита пока нет", "Здесь появятся действия оператора и системные события по устройству.");
return;
}
for (const event of events) {
const row = document.createElement("div");
row.className = "row audit";
row.innerHTML = `
<small>${formatDate(event.created_at)}</small>
<strong>${escapeHtml(event.action)}</strong>
<small>${escapeHtml(event.command_id || event.device_id || "-")}</small>
`;
els.auditList.appendChild(row);
}
}
async function loadRemoteSessions() {
if (!state.selectedDeviceId) return;
const data = await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/remote-sessions?limit=25`);
state.remoteSessions = data.remote_sessions || [];
renderRemoteSessions(state.remoteSessions);
}
function renderRemoteSessions(sessions) {
els.remoteSessionList.innerHTML = "";
const active = sessions.filter((session) => ["requested", "queued", "active"].includes(session.status)).length;
els.remoteSummary.textContent = active ? `${active} активн.` : "Нет активных сессий";
renderCloudAccessState(sessions);
if (sessions.length === 0) {
els.remoteSessionList.innerHTML = inlineStateMarkup("Удаленный доступ еще не открывался", "Создайте временную сессию, чтобы подключиться к роутеру по SSH или открыть LuCI.");
return;
}
for (const session of sessions) {
const canClose = ["requested", "queued", "active"].includes(session.status);
const endpoint = `${session.server_host || "-"}:${session.remote_port || "-"}`;
const connectCommand = session.remote_port ? `ssh -p ${session.remote_port} root@${session.server_host || "server"}` : "-";
const canOpenLuCI = session.status === "active" && session.luci_port;
const row = document.createElement("div");
row.className = "remote-session-row";
row.innerHTML = `
<div class="remote-session-main">
<span class="remote-session-status ${escapeHtml(session.status || "")}"><i></i>${escapeHtml(remoteStatusLabel(session.status))}</span>
<div>
<strong>Доступ к роутеру</strong>
<small>${escapeHtml(endpoint)} · до ${escapeHtml(formatShortDate(session.expires_at))}</small>
</div>
</div>
<code>${escapeHtml(connectCommand)}</code>
<div class="row-actions">
<button type="button" data-action="copy" ${session.remote_port ? "" : "disabled"}>Копировать SSH</button>
<button class="primary" type="button" data-action="luci" ${canOpenLuCI ? "" : "disabled"}>Открыть LuCI</button>
<button type="button" data-action="close" ${canClose ? "" : "disabled"}>Закрыть</button>
</div>
`;
row.querySelector('[data-action="luci"]').addEventListener("click", () => {
openLuCIAccess(session).catch(reportError);
});
row.querySelector('[data-action="copy"]').addEventListener("click", async () => {
await navigator.clipboard.writeText(connectCommand);
notify("SSH-команда скопирована", "success");
});
row.querySelector('[data-action="close"]').addEventListener("click", () => closeRemoteSession(session.id));
els.remoteSessionList.appendChild(row);
}
}
function renderCloudAccessState(sessions) {
const device = currentDevice();
const session = sessions.find((item) => ["requested", "queued", "active"].includes(item.status));
let accessState = session && session.access_state ? session.access_state : "closed";
if (!device || !device.online) accessState = "offline";
const copy = {
ready: ["Защищённый канал готов", "Открыть LuCI"],
starting: ["Создаём защищённый канал…", "Проверить"],
unavailable: ["Канал не открылся — нужна диагностика", "Повторить"],
offline: ["Роутер не на связи", "Проверить"],
closed: ["Соединение создаётся только по вашему запросу", "Открыть LuCI"],
}[accessState] || ["Состояние соединения неизвестно", "Проверить"];
els.cloudAccessCard.dataset.state = accessState;
els.cloudAccessStatus.textContent = copy[0];
els.openCloudAccessBtn.textContent = copy[1];
}
function prepareCloudAccessPopup(popup) {
if (!popup) return;
try {
popup.document.open();
popup.document.write(`<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Подключение к LuCI — OpenWrt RMM</title><link rel="stylesheet" href="/styles.css?v=27"></head><body class="cloud-wait-page"><main><span class="cloud-wait-mark">R</span><p class="eyebrow">Защищённый доступ</p><h1>Подключаемся к LuCI</h1><p>Создаём временный туннель и проверяем ответ роутера. Эта вкладка откроется автоматически.</p><div class="cloud-wait-progress" aria-label="Подключение выполняется"><i></i></div></main></body></html>`);
popup.document.close();
popup.opener = null;
} catch {
// The popup may be restricted by the browser; navigation below still works.
}
}
async function openCloudAccess() {
if (!state.selectedDeviceId || openCloudAccess.inFlight) return;
openCloudAccess.inFlight = true;
const popup = window.open("", "_blank");
prepareCloudAccessPopup(popup);
els.openLuciBtn.disabled = true;
els.openCloudAccessBtn.disabled = true;
els.openLuciBtn.classList.add("is-loading");
els.openCloudAccessBtn.classList.add("is-loading");
els.openLuciBtn.textContent = "Подключаемся…";
els.openCloudAccessBtn.textContent = "Подключаемся…";
els.cloudAccessCard.dataset.state = "starting";
els.cloudAccessStatus.textContent = "Связываемся с агентом и проверяем LuCI…";
try {
let response = await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/cloud-access`, { method: "POST" });
for (let attempt = 0; response.status === "starting" && attempt < 10; attempt += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 1500));
response = await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/cloud-access`);
}
if (response.status !== "ready") {
const error = new Error("cloud tunnel is not ready");
error.status = response.status === "unavailable" ? 503 : 409;
error.code = response.status;
throw error;
}
if (!response.url) {
response = await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/cloud-access`, { method: "POST" });
}
if (popup) {
popup.opener = null;
popup.location = response.url;
} else {
window.location.assign(response.url);
}
await loadRemoteSessions();
} catch (error) {
if (popup) popup.close();
showLuCIState(error, "retry");
await loadRemoteSessions().catch(() => {});
} finally {
openCloudAccess.inFlight = false;
els.openLuciBtn.classList.remove("is-loading");
els.openCloudAccessBtn.classList.remove("is-loading");
els.openLuciBtn.textContent = "Открыть LuCI";
renderCloudAccessState(state.remoteSessions || []);
els.openLuciBtn.disabled = !state.selectedDeviceId;
els.openCloudAccessBtn.disabled = false;
}
}
openCloudAccess.inFlight = false;
async function sendCommand() {
if (!state.selectedDeviceId) return;
if (!confirmDanger(els.commandType.value)) return;
setStatus("Creating command");
await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/commands`, {
method: "POST",
body: JSON.stringify({
type: els.commandType.value,
args: commandArgs(),
}),
});
await Promise.all([loadCommands(), loadAudit()]);
notify("Command queued", "success");
}
async function queueAgentUpdate() {
const device = currentDevice();
if (!device || !hasAgentUpdateAvailable(device)) return;
const targetVersion = stableAgentVersion();
if (!window.confirm(`Обновить агент на роутере до версии ${targetVersion}? Во время обновления связь с роутером может кратковременно прерваться.`)) return;
els.updateAgentBtn.disabled = true;
els.updateAgentBtn.textContent = "Добавление в очередь...";
try {
const command = await api(`/api/devices/${encodeURIComponent(device.id)}/agent-update`, { method: "POST" });
state.commands = [command, ...state.commands.filter((item) => item.id !== command.id)];
renderCommands(state.commands);
notify("Обновление агента добавлено в очередь", "success");
await Promise.all([loadCommands(), loadAudit()]);
} finally {
els.updateAgentBtn.disabled = false;
els.updateAgentBtn.textContent = "Обновить агент";
}
}
function uciConfigArg() {
return { config: els.uciConfig.value };
}
function uciSetArgs() {
return {
config: els.uciConfig.value,
section: els.uciSection.value.trim(),
option: els.uciOption.value.trim(),
value: els.uciValue.value,
commit: els.uciCommitNow.checked ? "true" : "false",
};
}
async function createDeviceCommand(type, args, options = {}) {
if (!state.selectedDeviceId) return;
if (!options.skipConfirm && !confirmDanger(type)) return;
const command = await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/commands`, {
method: "POST",
body: JSON.stringify({ type, args }),
});
if (!options.skipRefresh) {
await Promise.all([loadCommands(), loadAudit(), loadAlerts()]);
}
return command;
}
async function sendBulkCommand() {
const devices = filteredDevices();
if (devices.length === 0) {
setStatus("No filtered devices");
return;
}
const type = els.bulkCommandType.value;
if (!confirmDanger(type)) return;
setStatus(`Queueing ${type} for ${devices.length} devices`);
await api("/api/devices/bulk-commands", {
method: "POST",
body: JSON.stringify({
device_ids: devices.map((device) => device.id),
type,
args: bulkCommandArgs(),
}),
});
await Promise.all([loadCommands(), loadAudit()]);
notify(`${type} queued for ${devices.length} devices`, "success");
}
async function saveFleetMetadata() {
if (!state.selectedDeviceId) return;
setStatus("Saving fleet metadata");
const tags = els.fleetTags.value.split(",").map((tag) => tag.trim()).filter(Boolean);
const device = await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/fleet`, {
method: "PATCH",
body: JSON.stringify({ group: els.fleetGroup.value.trim(), tags }),
});
state.devices = state.devices.map((item) => (item.id === device.id ? device : item));
renderDevices();
renderDeviceDetail(device);
await loadAudit();
notify("Fleet metadata saved", "success");
}
async function sendPackageCommand() {
if (!state.selectedDeviceId) return;
const type = els.packageCommand.value;
const packageName = els.packageName.value.trim();
if ((type === "pkg_install" || type === "pkg_remove") && !packageName) {
setStatus("Package name is required");
return;
}
if (!confirmDanger(type)) return;
setStatus(`Queueing ${type}`);
await createDeviceCommand(type, packageName ? { package: packageName } : {}, { skipConfirm: true });
notify(`${type} queued`, "success");
}
async function sendUciCommand(type) {
if (!state.selectedDeviceId) return;
if ((type === "uci_set" || type === "uci_preview") && (!els.uciSection.value.trim() || !els.uciOption.value.trim())) {
setStatus("UCI section and option are required");
return;
}
if (!confirmDanger(type)) return;
setStatus(`Queueing ${type}`);
const args = type === "uci_set" || type === "uci_preview" ? uciSetArgs() : uciConfigArg();
await createDeviceCommand(type, args, { skipConfirm: true });
notify(`${type} queued`, "success");
}
async function createRemoteSession() {
if (!state.selectedDeviceId) return;
const serverHost = els.remoteServerHost.value.trim() || window.location.hostname;
const serverPort = Number(els.remoteServerPort.value || 2222);
const remotePort = Number(els.remotePort.value || 0);
const localPort = Number(els.remoteLocalPort.value || 22);
const luciScheme = els.remoteLuCIScheme.value || "http";
const durationSeconds = Number(els.remoteDuration.value || 900);
if (!serverHost) {
setStatus("Tunnel server is required");
return;
}
if (!window.confirm(`Открыть временный доступ к роутеру на ${Math.round(durationSeconds / 60)} минут?`)) return;
setStatus("Открытие удаленного доступа");
await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/remote-sessions`, {
method: "POST",
body: JSON.stringify({
target: "ssh",
server_host: serverHost,
server_port: serverPort,
remote_port: remotePort,
local_port: localPort,
luci_scheme: luciScheme,
duration_seconds: durationSeconds,
}),
});
await Promise.all([loadRemoteSessions(), loadCommands(), loadAudit()]);
notify("Команда открытия доступа отправлена", "success");
}
async function closeRemoteSession(sessionId) {
if (!state.selectedDeviceId) return;
if (!window.confirm("Закрыть удаленный доступ к роутеру?")) return;
setStatus("Закрытие удаленного доступа");
await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/remote-sessions/${encodeURIComponent(sessionId)}/close`, {
method: "POST",
});
await Promise.all([loadRemoteSessions(), loadAudit()]);
notify("Удаленный доступ закрыт", "success");
}
function presetCommand(preset) {
switch (preset) {
case "lan_ip":
return {
config: "network",
section: "lan",
option: "ipaddr",
value: els.presetLanIp.value.trim(),
commit: "false",
};
case "hostname":
return {
config: "system",
section: "@system[0]",
option: "hostname",
value: els.presetHostname.value.trim(),
commit: "false",
};
case "wifi_ssid":
return {
config: "wireless",
section: "@wifi-iface[0]",
option: "ssid",
value: els.presetWifiSsid.value.trim(),
commit: "false",
};
case "wifi_key":
return {
config: "wireless",
section: "@wifi-iface[0]",
option: "key",
value: els.presetWifiKey.value,
commit: "false",
};
case "dhcp_lan":
return {
config: "dhcp",
section: "lan",
option: "ignore",
value: els.presetDhcpLan.value,
commit: "false",
};
default:
return null;
}
}
function presetLabel(preset) {
return {
lan_ip: "LAN-адрес",
hostname: "Имя устройства",
wifi_ssid: "Название Wi-Fi",
wifi_key: "Пароль Wi-Fi",
dhcp_lan: "DHCP-сервер",
}[preset] || preset;
}
function presetDisplayValue(preset, value) {
if (preset === "wifi_key") return "Новый пароль будет установлен";
if (preset === "dhcp_lan") return value === "0" ? "Включен" : "Отключен";
return value;
}
function presetSafeOutput(review, output) {
if (review.preset !== "wifi_key") return output || "";
return String(output || "").replaceAll(review.args.value, "********");
}
async function reviewPreset(preset) {
const args = presetCommand(preset);
if (!args) return;
if (!args.value) {
setStatus("Заполните значение настройки");
return;
}
setStatus("Проверка изменения");
const command = await createDeviceCommand("uci_preview", args, { skipConfirm: true });
state.presetReview = { preset, args, previewCommandId: command.id, applyCommandIds: [] };
renderPresetReview();
els.presetReviewPanel.scrollIntoView({ behavior: "smooth", block: "center" });
setStatus("Изменение отправлено на проверку");
}
function renderPresetReview() {
const review = state.presetReview;
if (!review) {
els.presetReviewPanel.classList.add("is-hidden");
return;
}
els.presetReviewPanel.classList.remove("is-hidden");
els.presetReviewTitle.textContent = presetLabel(review.preset);
els.presetReviewChange.innerHTML = `
<span>${escapeHtml(`${review.args.config}.${review.args.section}.${review.args.option}`)}</span>
<strong>${escapeHtml(presetDisplayValue(review.preset, review.args.value))}</strong>
`;
const preview = state.commands.find((command) => command.id === review.previewCommandId);
const applying = review.applyCommandIds.map((id) => state.commands.find((command) => command.id === id)).filter(Boolean);
const applyFailed = applying.some((command) => ["failed", "cancelled", "expired"].includes(command.status));
const applyDone = applying.length === 2 && applying.every((command) => command.status === "completed");
if (applyFailed) {
els.presetReviewStatus.textContent = "Ошибка применения";
els.presetReviewOutput.textContent = presetSafeOutput(review, applying.map((command) => command.output || `${command.type}: ${command.status}`).join("\n\n"));
els.applyPresetReviewBtn.disabled = true;
return;
}
if (applyDone) {
els.presetReviewStatus.textContent = "Применено безопасно";
els.presetReviewOutput.textContent = presetSafeOutput(review, applying.map((command) => command.output || `${command.type}: completed`).join("\n\n"));
els.applyPresetReviewBtn.disabled = true;
return;
}
if (applying.length) {
els.presetReviewStatus.textContent = "Применяется";
els.presetReviewOutput.textContent = "Настройка применяется. После commit confirmed роутер проверит связь с сервером.";
els.applyPresetReviewBtn.disabled = true;
return;
}
if (!preview || ["queued", "claimed"].includes(preview.status)) {
els.presetReviewStatus.textContent = "Проверяется";
els.presetReviewOutput.textContent = "Ожидание результата проверки от роутера...";
els.applyPresetReviewBtn.disabled = true;
return;
}
if (preview.status !== "completed") {
els.presetReviewStatus.textContent = "Проверка не пройдена";
els.presetReviewOutput.textContent = presetSafeOutput(review, preview.output || `Статус: ${preview.status}`);
els.applyPresetReviewBtn.disabled = true;
return;
}
els.presetReviewStatus.textContent = "Готово к применению";
els.presetReviewOutput.textContent = presetSafeOutput(review, preview.output || "Изменение проверено");
els.applyPresetReviewBtn.disabled = false;
}
async function applyPresetReview() {
const review = state.presetReview;
if (!review) return;
const preview = state.commands.find((command) => command.id === review.previewCommandId);
if (!preview || preview.status !== "completed") {
setStatus("Сначала дождитесь успешной проверки");
return;
}
if (!window.confirm("Применить проверенное изменение? При потере связи роутер автоматически восстановит конфигурацию.")) return;
const staged = await createDeviceCommand("uci_set", review.args, { skipConfirm: true, skipRefresh: true });
const confirmed = await createDeviceCommand("uci_commit_confirmed", {
config: review.args.config,
confirm_seconds: "15",
}, { skipConfirm: true, skipRefresh: true });
review.applyCommandIds = [staged.id, confirmed.id];
await Promise.all([loadCommands(), loadAudit(), loadAlerts()]);
renderPresetReview();
notify("Безопасное применение запущено", "success");
}
function cancelPresetReview() {
state.presetReview = null;
renderPresetReview();
setStatus("Изменение отменено");
}
async function cancelCommand(commandId) {
if (!state.selectedDeviceId) return;
setStatus("Cancelling command");
await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/commands/${encodeURIComponent(commandId)}/cancel`, {
method: "POST",
});
await Promise.all([loadCommands(), loadAudit()]);
setStatus("Command cancelled");
}
async function acknowledgeAlert(alertId) {
if (!state.selectedDeviceId) return;
setStatus("Acknowledging alert");
await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/alerts/${encodeURIComponent(alertId)}/acknowledge`, {
method: "POST",
});
await Promise.all([loadAlerts(), loadAudit(), loadDevices()]);
notify("Alert acknowledged", "success");
}
async function clearDeviceAlerts() {
if (!state.selectedDeviceId) return;
if (!confirmTyped("Очистить все алерты выбранного устройства?", "CLEAR")) return;
setStatus("Очищаю алерты");
await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/alerts`, { method: "DELETE" });
await Promise.all([loadAlerts(), loadAudit(), loadDevices()]);
notify("Алерты очищены", "success");
}
async function clearDeviceCommands() {
if (!state.selectedDeviceId) return;
if (!confirmTyped("Очистить историю команд выбранного устройства?", "CLEAR")) return;
setStatus("Очищаю историю команд");
await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/commands`, { method: "DELETE" });
state.commands = [];
state.commandOffset = 0;
state.selectedCommand = null;
await Promise.all([loadCommands(), loadAudit(), loadDevices()]);
renderCommandDetail();
notify("История команд очищена", "success");
}
async function clearDeviceAudit() {
if (!state.selectedDeviceId) return;
if (!confirmTyped("Очистить аудит выбранного устройства?", "CLEAR")) return;
setStatus("Очищаю аудит");
await api(`/api/audit-events?device_id=${encodeURIComponent(state.selectedDeviceId)}`, { method: "DELETE" });
state.auditEvents = [];
state.auditOffset = 0;
await loadAudit();
notify("Аудит устройства очищен", "success");
}
async function deleteSelectedDevice() {
const device = currentDevice();
if (!device) return;
const name = deviceDisplayName(device);
if (!confirmTyped(`Удалить устройство "${name}" из RMM? Это удалит метрики, команды, алерты и remote sessions.`, name)) return;
setStatus("Удаляю устройство");
await api(`/api/devices/${encodeURIComponent(device.id)}`, { method: "DELETE" });
state.selectedDeviceId = null;
state.selectedCommand = null;
state.commands = [];
state.auditEvents = [];
await loadDevices();
render();
notify("Устройство удалено", "success");
}
async function transferSelectedDevice() {
const device = currentDevice();
if (!device) return;
const targetUsername = els.transferUsername.value.trim();
if (!window.confirm(`Передать роутер «${deviceDisplayName(device)}» пользователю ${targetUsername}? Текущий удалённый доступ будет закрыт.`)) return;
setFormMessage(els.transferMessage, "Передаём…");
await api(`/api/devices/${encodeURIComponent(device.id)}/transfer`, {
method: "POST",
body: JSON.stringify({ target_username: targetUsername, current_password: els.transferPassword.value }),
});
els.deviceTransferForm.reset();
state.selectedDeviceId = null;
await loadDevices();
render();
notify(`Роутер передан пользователю ${targetUsername}`, "success");
}
function diagnosticCommand(name) {
const serverHost = window.location.hostname || "10.10.10.2";
switch (name) {
case "ping_server":
return { type: "ping", args: { target: serverHost } };
case "ping_internet":
return { type: "ping", args: { target: "1.1.1.1" } };
case "traceroute_internet":
return { type: "traceroute", args: { target: "1.1.1.1" } };
case "show_routes":
return { type: "route_show", args: {} };
case "show_interfaces":
return { type: "interfaces_show", args: {} };
default:
return null;
}
}
async function sendDiagnostic(name) {
const command = diagnosticCommand(name);
if (!command) return;
setStatus("Запуск проверки");
await createDeviceCommand(command.type, command.args);
notify("Проверка поставлена в очередь", "success");
scrollToCommands();
}
async function runFullDiagnostic() {
const checks = ["ping_server", "ping_internet", "show_routes", "show_interfaces"];
els.runFullDiagnosticBtn.disabled = true;
els.diagnosticStatus.classList.add("is-running");
els.diagnosticStatus.innerHTML = `
<span class="operation-icon">↻</span>
<div><strong>Диагностика запускается</strong><small>Отправляем проверки на роутер</small></div>
`;
try {
for (const name of checks) {
const command = diagnosticCommand(name);
await createDeviceCommand(command.type, command.args, { skipRefresh: true });
}
await Promise.all([loadCommands(), loadAudit(), loadAlerts()]);
els.diagnosticStatus.classList.remove("is-running");
els.diagnosticStatus.classList.add("is-complete");
els.diagnosticStatus.innerHTML = `
<span class="operation-icon">✓</span>
<div><strong>Диагностика запущена</strong><small>Результаты появятся в истории команд</small></div>
<button id="openDiagnosticResultsBtn" type="button">Открыть результаты</button>
`;
els.diagnosticStatus.querySelector("#openDiagnosticResultsBtn").addEventListener("click", scrollToCommands);
notify("Полная диагностика поставлена в очередь", "success");
} finally {
els.runFullDiagnosticBtn.disabled = false;
}
}
async function runAlertDiagnostics(alert) {
const target = alert.details && alert.details.target ? alert.details.target : "1.1.1.1";
if (alert.type === "latency_high" || alert.type === "packet_loss_high" || alert.type === "wan_down") {
await createDeviceCommand("ping", { target });
} else if (alert.type === "offline") {
await createDeviceCommand("ping", { target: window.location.hostname || "10.10.10.2" });
} else {
scrollToCommands();
return;
}
notify("Diagnostic queued", "success");
scrollToCommands();
}
function scrollToCommands() {
selectDeviceTab("expert");
document.querySelector("#commandsPanel")?.scrollIntoView({ behavior: "smooth", block: "start" });
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function createEnrollmentGrant() {
const suggested = (window.prompt("Имя роутера в домене (например office-1); можно оставить пустым", "") || "").trim().toLowerCase();
const data = await api("/api/enrollment-grants", {
method: "POST",
body: JSON.stringify({ dns_label: suggested, expires_seconds: 900 }),
});
els.enrollmentTokenOutput.value = data.enrollment_token;
els.enrollmentGrantDialog.showModal();
notify("Grant для добавления роутера создан", "success");
}
async function createUserAccount() {
const username = els.newUsername.value.trim();
const password = els.newUserPassword.value;
await api("/api/users", {
method: "POST",
body: JSON.stringify({ username, email: els.newUserEmail.value.trim(), password, role: els.newUserRole.value }),
});
els.createUserDialog.close();
els.createUserForm.reset();
if (els.profileDialog.open) await loadUsers();
notify(`Пользователь ${username} создан`, "success");
}
async function checkHealth() {
try {
const response = await fetch("/healthz");
els.apiState.textContent = response.ok ? "API: online" : "API: degraded";
} catch {
els.apiState.textContent = "API: offline";
}
}
initFleetTableSorting();
els.loginForm.addEventListener("submit", (event) => {
event.preventDefault();
login().catch((error) => showLogin(error.message));
});
els.forgotPasswordBtn.addEventListener("click", () => {
setFormMessage(els.forgotPasswordMessage, "");
els.forgotPasswordDialog.showModal();
});
els.closeForgotPasswordBtn.addEventListener("click", () => els.forgotPasswordDialog.close());
els.forgotPasswordForm.addEventListener("submit", (event) => {
event.preventDefault();
requestPasswordReset().catch((error) => setFormMessage(els.forgotPasswordMessage, error.message, "error"));
});
els.closePasswordResetBtn.addEventListener("click", () => els.passwordResetDialog.close());
els.passwordResetForm.addEventListener("submit", (event) => {
event.preventDefault();
confirmPasswordReset().catch((error) => setFormMessage(els.passwordResetMessage, error.message, "error"));
});
els.logoutBtn.addEventListener("click", () => logout().catch(reportError));
els.fleetNavBtn.addEventListener("click", showFleet);
els.problemsNavBtn.addEventListener("click", () => openDeviceArea("overview", "#alertList", "problems").catch(reportError));
els.operationsNavBtn.addEventListener("click", () => openDeviceArea("operations", "#remoteAccessPanel", "operations").catch(reportError));
els.profileBtn.addEventListener("click", showProfile);
els.notificationCenterBtn.addEventListener("click", () => showNotificationCenter().catch(reportError));
els.closeNotificationCenterBtn.addEventListener("click", () => els.notificationCenterDialog.close());
els.notificationCenterDialog.addEventListener("cancel", (event) => {
event.preventDefault();
els.notificationCenterDialog.close();
});
els.markAllNotificationsReadBtn.addEventListener("click", () => markAllNotificationsRead().catch(reportError));
for (const button of document.querySelectorAll(".mobile-nav-item")) {
button.addEventListener("click", () => {
const route = button.dataset.mobileRoute;
if (route === "fleet") showFleet();
if (route === "problems") openDeviceArea("overview", "#alertList", "problems").catch(reportError);
if (route === "operations") openDeviceArea("operations", "#remoteAccessPanel", "operations").catch(reportError);
if (route === "notifications") showNotificationCenter().catch(reportError);
if (route === "profile") showProfile();
});
}
els.closeProfileBtn.addEventListener("click", closeProfile);
for (const button of els.profileTabs) {
button.addEventListener("click", () => selectProfileTab(button.dataset.profileTab));
button.addEventListener("keydown", (event) => {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const available = els.profileTabs.filter((item) => !item.classList.contains("is-hidden"));
const current = available.indexOf(button);
let next = event.key === "Home" ? 0 : event.key === "End" ? available.length - 1 : current + (event.key === "ArrowRight" ? 1 : -1);
next = (next + available.length) % available.length;
selectProfileTab(available[next].dataset.profileTab);
available[next].focus();
});
}
els.profileDialog.addEventListener("cancel", (event) => {
event.preventDefault();
closeProfile();
});
els.profileLogoutBtn.addEventListener("click", () => {
closeProfile();
logout().catch(reportError);
});
els.profileForm.addEventListener("submit", (event) => {
event.preventDefault();
saveProfile().catch((error) => setFormMessage(els.profileMessage, error.message, "error"));
});
els.passwordForm.addEventListener("submit", (event) => {
event.preventDefault();
changePassword().catch((error) => setFormMessage(els.passwordMessage, error.message, "error"));
});
els.notificationSettingsForm.addEventListener("submit", (event) => {
event.preventDefault();
saveNotificationSettings().catch((error) => setFormMessage(els.notificationSettingsMessage, notificationErrorMessage(error), "error"));
});
els.testNotificationsBtn.addEventListener("click", () => {
testNotifications().catch((error) => setFormMessage(els.notificationSettingsMessage, notificationErrorMessage(error), "error"));
});
els.refreshNotificationsBtn.addEventListener("click", () => {
loadNotificationHistory().catch((error) => setFormMessage(els.notificationSettingsMessage, notificationErrorMessage(error), "error"));
});
for (const filter of [
els.notificationFilterDevice,
els.notificationFilterSeverity,
els.notificationFilterEvent,
els.notificationFilterChannel,
els.notificationFilterStatus,
]) {
filter.addEventListener("change", () => {
loadNotificationHistory().catch((error) => setFormMessage(els.notificationSettingsMessage, notificationErrorMessage(error), "error"));
});
}
els.clearNotificationFiltersBtn.addEventListener("click", () => {
els.notificationFilterDevice.value = "";
els.notificationFilterSeverity.value = "";
els.notificationFilterEvent.value = "";
els.notificationFilterChannel.value = "";
els.notificationFilterStatus.value = "";
loadNotificationHistory().catch((error) => setFormMessage(els.notificationSettingsMessage, notificationErrorMessage(error), "error"));
});
els.verifyEmailBtn.addEventListener("click", () => requestContactVerification("email").catch(reportError));
els.verifyTelegramBtn.addEventListener("click", () => requestContactVerification("telegram").catch(reportError));
els.confirmEmailBtn.addEventListener("click", () => confirmContactVerification("email").catch(reportError));
els.confirmTelegramBtn.addEventListener("click", () => confirmContactVerification("telegram").catch(reportError));
els.notificationDeviceSelect.addEventListener("change", () => loadDeviceNotificationSettings().catch(reportError));
els.saveDeviceNotificationSettingsBtn.addEventListener("click", () => saveDeviceNotificationSettings().catch(reportError));
els.logoutAllBtn.addEventListener("click", () => logoutAll().catch(reportError));
els.closeLuciStateBtn.addEventListener("click", () => els.luciStateDialog.close());
els.luciStatePrimaryBtn.addEventListener("click", () => runLuCIPrimaryAction().catch(reportError));
els.luciStateDiagnosticBtn.addEventListener("click", () => {
els.luciStateDialog.close();
openDeviceArea("operations", "#diagnosticStatus", "operations").catch(reportError);
});
els.luciStateDialog.addEventListener("cancel", (event) => {
event.preventDefault();
els.luciStateDialog.close();
});
els.fleetFilterToggle.addEventListener("click", () => {
const isOpen = els.fleetAdvancedFilters.classList.toggle("is-open");
els.fleetFilterToggle.setAttribute("aria-expanded", String(isOpen));
els.fleetFilterToggle.textContent = isOpen ? "Скрыть" : "Фильтры";
});
els.refreshBtn.addEventListener("click", () => loadDevices().catch(reportError));
els.addRouterBtn.addEventListener("click", () => createEnrollmentGrant().catch(reportError));
els.addUserBtn.addEventListener("click", () => els.createUserDialog.showModal());
els.createUserForm.addEventListener("submit", (event) => {
event.preventDefault();
createUserAccount().catch(reportError);
});
els.cancelCreateUserBtn.addEventListener("click", () => els.createUserDialog.close());
els.copyEnrollmentTokenBtn.addEventListener("click", async () => {
await navigator.clipboard.writeText(els.enrollmentTokenOutput.value);
notify("Grant скопирован", "success");
});
els.backToFleetBtn.addEventListener("click", showFleet);
els.quickDiagnosticBtn.addEventListener("click", () => selectDeviceTab("operations"));
els.openLuciBtn.addEventListener("click", openLuciOrRemoteAccess);
els.updateAgentBtn.addEventListener("click", () => queueAgentUpdate().catch(reportError));
els.runFullDiagnosticBtn.addEventListener("click", () => runFullDiagnostic().catch(reportError));
els.loadMoreCommandsBtn.addEventListener("click", () => loadCommands({ append: true }).catch(reportError));
els.loadMoreAuditBtn.addEventListener("click", () => loadAudit({ append: true }).catch(reportError));
els.alertStatusFilter.addEventListener("change", () => {
state.alertStatusFilter = els.alertStatusFilter.value;
loadAlerts().catch(reportError);
});
els.sendCommandBtn.addEventListener("click", () => sendCommand().catch(reportError));
els.sendBulkCommandBtn.addEventListener("click", () => sendBulkCommand().catch(reportError));
els.saveFleetBtn.addEventListener("click", () => saveFleetMetadata().catch(reportError));
els.clearAlertsBtn.addEventListener("click", () => clearDeviceAlerts().catch(reportError));
els.clearCommandsBtn.addEventListener("click", () => clearDeviceCommands().catch(reportError));
els.clearAuditBtn.addEventListener("click", () => clearDeviceAudit().catch(reportError));
els.deleteDeviceBtn.addEventListener("click", () => deleteSelectedDevice().catch(reportError));
els.deviceTransferForm.addEventListener("submit", (event) => {
event.preventDefault();
transferSelectedDevice().catch((error) => setFormMessage(els.transferMessage, error.message, "error"));
});
els.sendPackageCommandBtn.addEventListener("click", () => sendPackageCommand().catch(reportError));
els.createRemoteSessionBtn.addEventListener("click", () => createRemoteSession().catch(reportError));
els.openCloudAccessBtn.addEventListener("click", () => openCloudAccess().catch(reportError));
els.uciBackupBtn.addEventListener("click", () => sendUciCommand("uci_backup").catch(reportError));
els.uciPreviewBtn.addEventListener("click", () => sendUciCommand("uci_preview").catch(reportError));
els.uciShowBtn.addEventListener("click", () => sendUciCommand("uci_show").catch(reportError));
els.uciSetBtn.addEventListener("click", () => sendUciCommand("uci_set").catch(reportError));
els.uciCommitBtn.addEventListener("click", () => sendUciCommand("uci_commit").catch(reportError));
els.uciCommitConfirmedBtn.addEventListener("click", () => sendUciCommand("uci_commit_confirmed").catch(reportError));
els.uciRevertBtn.addEventListener("click", () => sendUciCommand("uci_revert").catch(reportError));
els.uciRestoreBtn.addEventListener("click", () => sendUciCommand("uci_restore").catch(reportError));
els.copyCommandOutputBtn.addEventListener("click", async () => {
if (!state.selectedCommand) return;
const output = state.selectedCommand.output || JSON.stringify(state.selectedCommand.args || {}, null, 2);
await navigator.clipboard.writeText(output);
notify("Output copied", "success");
});
for (const button of document.querySelectorAll(".copy-info-btn")) {
button.addEventListener("click", async () => {
const source = document.querySelector(`#${button.dataset.copyInfo}`);
if (!source) return;
await navigator.clipboard.writeText(source.textContent);
notify("Значение скопировано", "success");
});
}
for (const button of document.querySelectorAll(".preset-review-btn")) {
button.addEventListener("click", () => {
reviewPreset(button.dataset.preset).catch(reportError);
});
}
els.applyPresetReviewBtn.addEventListener("click", () => applyPresetReview().catch(reportError));
els.cancelPresetReviewBtn.addEventListener("click", cancelPresetReview);
for (const button of document.querySelectorAll(".diagnostic-btn")) {
button.addEventListener("click", () => {
sendDiagnostic(button.dataset.diagnostic).catch(reportError);
});
}
for (const button of document.querySelectorAll(".device-tab")) {
button.addEventListener("click", () => selectDeviceTab(button.dataset.deviceTabTarget));
}
for (const button of document.querySelectorAll(".client-filter")) {
button.addEventListener("click", () => {
state.clientFilter = button.dataset.clientFilter;
document.querySelectorAll(".client-filter").forEach((item) => item.classList.toggle("is-active", item === button));
renderClients(currentDevice());
});
}
els.clientSearch.addEventListener("input", () => renderClients(currentDevice()));
for (const button of document.querySelectorAll(".filter")) {
button.addEventListener("click", () => {
state.filter = button.dataset.filter;
document.querySelectorAll(".filter").forEach((item) => item.classList.toggle("is-active", item === button));
renderDevices();
});
}
for (const input of [els.fleetSearch, els.fleetGroupFilter, els.fleetTagFilter]) {
input.addEventListener("input", renderDevices);
}
for (const button of document.querySelectorAll(".table-sort")) {
button.addEventListener("click", () => {
const nextKey = button.dataset.sortKey;
const defaultDir = button.dataset.sortDefaultDir || "asc";
if (state.fleetSortKey === nextKey) {
state.fleetSortDir = state.fleetSortDir === "asc" ? "desc" : "asc";
} else {
state.fleetSortKey = nextKey;
state.fleetSortDir = defaultDir;
}
renderDevices();
});
}
for (const button of document.querySelectorAll(".command-filter")) {
button.addEventListener("click", () => {
state.commandFilter = button.dataset.commandFilter;
document.querySelectorAll(".command-filter").forEach((item) => item.classList.toggle("is-active", item === button));
renderCommands(state.commands);
});
}
els.commandStatusFilter.addEventListener("change", () => {
state.commandStatusFilter = els.commandStatusFilter.value;
renderCommands(state.commands);
});
els.commandType.addEventListener("change", () => {
els.commandTarget.disabled = ["pkg_list_installed", "route_show", "interfaces_show", "reboot"].includes(els.commandType.value);
});
els.bulkCommandType.addEventListener("change", () => {
els.bulkCommandTarget.disabled = els.bulkCommandType.value === "pkg_list_installed";
});
openPasswordResetFromURL();
checkHealth();
checkSession();
setInterval(() => {
checkHealth();
if (!els.appShell.classList.contains("is-hidden") && !state.liveConnected) {
refreshDevicesIfIdle().catch(reportError);
}
}, 30000);
setInterval(updateLiveStateLabel, 10000);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible" && !els.appShell.classList.contains("is-hidden")) {
checkHealth();
refreshDevicesIfIdle().catch(reportError);
if (!eventSource || eventSource.readyState === EventSource.CLOSED) connectLiveUpdates();
}
});