Files
go-service/cmd/server/web/js/logs.ts
2026-06-23 09:07:43 +03:00

748 lines
27 KiB
TypeScript
Raw Permalink 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.

// js/logs.ts - Логика для страницы журнала логов
import { drawChart } from './chart.js';
// ============================================
// ТИПЫ
// ============================================
interface LogFileInfo {
name: string;
path: string;
size: number;
mod_time: string;
time: string;
date: string;
hour: number;
is_active: boolean;
}
interface LogSample {
ts: number;
time: string;
value: number;
amplitude: number;
signal: number;
strength_name: string;
}
interface LogDataResponse {
filename: string;
total: number;
page: number;
page_size: number;
total_pages: number;
has_previous: boolean;
has_next: boolean;
start_index: number;
end_index: number;
samples: LogSample[];
stats: {
total_points: number;
amplitude_over_0: number;
signal_over_10: number;
};
order: string;
}
interface Event {
time: string;
event: string;
}
interface EventsResponse {
events: Event[];
total: number;
page: number;
page_size: number;
total_pages: number;
has_previous: boolean;
has_next: boolean;
returned: number;
start_index: number;
end_index: number;
order?: string;
}
// ============================================
// СОСТОЯНИЕ
// ============================================
interface AppState {
currentFile: string | null;
currentData: LogDataResponse | null;
currentEvents: Event[];
currentPage: number;
totalPages: number;
isLoading: boolean;
// Для данных
dataPage: number;
dataTotalPages: number;
isLoadingData: boolean;
}
const state: AppState = {
currentFile: null,
currentData: null,
currentEvents: [],
currentPage: 1,
totalPages: 1,
isLoading: false,
dataPage: 1,
dataTotalPages: 1,
isLoadingData: false,
};
const PAGE_SIZE = 100;
const DATA_PAGE_SIZE = 100;
// ============================================
// DOM ЭЛЕМЕНТЫ
// ============================================
const DOM = {
fileList: document.getElementById('fileList') as HTMLElement | null,
logStats: document.getElementById('logStats') as HTMLElement | null,
dataTableContainer: document.getElementById('dataTableContainer') as HTMLElement | null,
logChart: document.getElementById('logChart') as HTMLCanvasElement | null,
eventsContent: document.getElementById('eventsContent') as HTMLElement | null,
serverTime: document.getElementById('server-time') as HTMLElement | null,
// Боковая панель
logsSidebar: document.querySelector('.logs-sidebar') as HTMLElement | null,
logsContainer: document.querySelector('.logs-container') as HTMLElement | null,
// Элементы пагинации для данных
dataPageInfo: document.getElementById('dataPageInfo') as HTMLElement | null,
dataPrevPageBtn: document.getElementById('dataPrevPage') as HTMLButtonElement | null,
dataNextPageBtn: document.getElementById('dataNextPage') as HTMLButtonElement | null,
dataFirstPageBtn: document.getElementById('dataFirstPage') as HTMLButtonElement | null,
dataLastPageBtn: document.getElementById('dataLastPage') as HTMLButtonElement | null,
dataPageInput: document.getElementById('dataPageInput') as HTMLInputElement | null,
dataGoToPageBtn: document.getElementById('dataGoToPage') as HTMLButtonElement | null,
// Элементы пагинации для событий
pageInfo: document.getElementById('pageInfo') as HTMLElement | null,
prevPageBtn: document.getElementById('prevPage') as HTMLButtonElement | null,
nextPageBtn: document.getElementById('nextPage') as HTMLButtonElement | null,
firstPageBtn: document.getElementById('firstPage') as HTMLButtonElement | null,
lastPageBtn: document.getElementById('lastPage') as HTMLButtonElement | null,
pageInput: document.getElementById('pageInput') as HTMLInputElement | null,
goToPageBtn: document.getElementById('goToPage') as HTMLButtonElement | null,
};
// ============================================
// УПРАВЛЕНИЕ БОКОВОЙ ПАНЕЛЬЮ
// ============================================
/**
* Показать/скрыть боковую панель в зависимости от активной вкладки
*/
function toggleSidebar(show: boolean): void {
if (!DOM.logsSidebar || !DOM.logsContainer) return;
if (show) {
// Показываем панель
DOM.logsSidebar.style.display = 'block';
DOM.logsSidebar.style.flex = '0 0 280px';
DOM.logsContainer.style.gap = '20px';
} else {
// Скрываем панель
DOM.logsSidebar.style.display = 'none';
DOM.logsSidebar.style.flex = '0 0 0';
DOM.logsContainer.style.gap = '0';
}
}
// ============================================
// ФУНКЦИИ ЗАГРУЗКИ ДАННЫХ
// ============================================
/**
* Загрузка списка файлов
*/
async function loadFileList(): Promise<void> {
try {
const response = await fetch('/api/log/files');
const files: LogFileInfo[] = await response.json();
if (!DOM.fileList) return;
DOM.fileList.innerHTML = '';
files.forEach((file: LogFileInfo) => {
const div = document.createElement('div');
div.className = 'log-file-item';
if (state.currentFile === file.name) {
div.classList.add('active');
}
div.innerHTML = `
<div class="file-name">${file.name}</div>
<div class="file-info">${file.time} | ${(file.size / 1024).toFixed(1)} KB</div>
`;
div.addEventListener('click', () => {
state.dataPage = 1;
void loadFileData(file.name, 1);
});
DOM.fileList?.appendChild(div);
});
// Автоматически загружаем первый файл
if (files.length > 0 && !state.currentFile) {
state.dataPage = 1;
await loadFileData(files[0].name, 1);
}
} catch (error) {
console.error('[Logs] Ошибка загрузки списка файлов:', error);
}
}
/**
* Загрузка данных из файла с пагинацией
*/
async function loadFileData(filename: string, page: number = 1): Promise<void> {
if (state.isLoadingData) return;
state.currentFile = filename;
state.dataPage = page;
// Обновляем активный элемент в списке
const items = document.querySelectorAll('.log-file-item');
Array.from(items).forEach((el) => {
el.classList.remove('active');
});
const activeItems = document.querySelectorAll('.log-file-item');
for (let i = 0; i < activeItems.length; i++) {
const item = activeItems[i] as HTMLElement;
if (item.textContent?.includes(filename)) {
item.classList.add('active');
break;
}
}
try {
state.isLoadingData = true;
// Показываем индикатор загрузки
if (DOM.dataTableContainer) {
DOM.dataTableContainer.innerHTML = '<div class="loading-indicator">⏳ Загрузка данных...</div>';
}
const url = `/api/log/data?file=${encodeURIComponent(filename)}&page=${page}&page_size=${DATA_PAGE_SIZE}`;
console.log(`[Logs] Загрузка данных: страница ${page} для ${filename}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data: LogDataResponse = await response.json();
state.currentData = data;
state.dataTotalPages = data.total_pages;
renderData(data);
updateDataPagination(data);
console.log(`[Logs] Загружено: ${data.samples.length} записей, Страница: ${page}/${data.total_pages}`);
} catch (error) {
console.error('[Logs] Ошибка загрузки данных:', error);
if (DOM.dataTableContainer) {
DOM.dataTableContainer.innerHTML = `
<div class="empty-state">
<div class="empty-message">Нет данных для отображения</div>
<div class="empty-hint">Файл пуст или данные ещё не поступили</div>
</div>
`;
}
if (DOM.dataPageInfo) {
DOM.dataPageInfo.textContent = 'Нет записей';
}
if (DOM.dataFirstPageBtn) DOM.dataFirstPageBtn.disabled = true;
if (DOM.dataPrevPageBtn) DOM.dataPrevPageBtn.disabled = true;
if (DOM.dataNextPageBtn) DOM.dataNextPageBtn.disabled = true;
if (DOM.dataLastPageBtn) DOM.dataLastPageBtn.disabled = true;
} finally {
state.isLoadingData = false;
}
}
/**
* Загрузка событий с пагинацией
*/
async function loadEvents(page: number = 1): Promise<void> {
if (state.isLoading) return;
try {
state.isLoading = true;
state.currentPage = page;
if (DOM.eventsContent) {
DOM.eventsContent.innerHTML = '<div class="loading-indicator">Загрузка событий...</div>';
}
const url = `/api/log/events?page=${page}&page_size=${PAGE_SIZE}&order=newest_first`;
console.log(`[Logs] Загрузка событий: страница ${page}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data: EventsResponse = await response.json();
state.totalPages = data.total_pages;
state.currentEvents = data.events || [];
renderEvents(data);
updatePagination(data);
console.log(`[Logs] Загружено: ${data.returned} событий, Страница: ${page}/${data.total_pages}`);
} catch (error) {
console.error('[Logs] Ошибка загрузки событий:', error);
if (DOM.eventsContent) {
DOM.eventsContent.innerHTML = `
<div class="empty-state">
<div class="empty-message">Нет событий</div>
<div class="empty-hint">Журнал событий пуст</div>
</div>
`;
}
} finally {
state.isLoading = false;
}
}
// ============================================
// ФУНКЦИИ ОТРИСОВКИ
// ============================================
/**
* Отрисовка данных файла
*/
function renderData(data: LogDataResponse): void {
const samples = data.samples || [];
// Статистика
if (DOM.logStats) {
const stats = data.stats || { total_points: 0, amplitude_over_0: 0, signal_over_10: 0 };
DOM.logStats.innerHTML = `
<div class="log-stat-card">
<div class="value">${stats.total_points}</div>
<div class="label">Всего точек</div>
</div>
<div class="log-stat-card">
<div class="value">${stats.amplitude_over_0}</div>
<div class="label">Амплитуда > 0</div>
</div>
<div class="log-stat-card">
<div class="value">${stats.signal_over_10}</div>
<div class="label">Сигнал > 10</div>
</div>
`;
}
// График
if (DOM.logChart && data.total > 0) {
const history = samples.map((s: LogSample) => s.signal);
drawChart(DOM.logChart, history);
}
// Таблица
if (samples.length > 0 && DOM.dataTableContainer) {
let html = `<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Время</th>
<th>Амплитуда</th>
<th>Сигнал</th>
</tr>
</thead>
<tbody>`;
samples.forEach((s: LogSample, index: number) => {
let ampClass = '';
if (s.amplitude === 0) ampClass = 'strength-0';
else if (s.amplitude === 1) ampClass = 'strength-1';
else if (s.amplitude === 2) ampClass = 'strength-2';
else if (s.amplitude === 3) ampClass = 'strength-3';
const num = index + 1;
html += `<tr>
<td style="opacity:0.3;font-size:var(--font-size-xs);">${num}</td>
<td>${s.time}</td>
<td class="${ampClass}">${s.amplitude}</td>
<td>${s.signal}</td>
</tr>`;
});
html += '</tbody></table>';
DOM.dataTableContainer.innerHTML = html;
} else if (DOM.dataTableContainer) {
DOM.dataTableContainer.innerHTML = `
<div class="empty-state">
<div class="empty-message">Нет данных</div>
<div class="empty-hint">Выберите файл с данными</div>
</div>
`;
}
}
/**
* Отрисовка событий
*/
function renderEvents(data: EventsResponse): void {
if (!DOM.eventsContent) return;
let html = '';
if (data.events.length === 0) {
html = `
<div class="empty-state">
<div class="empty-message">Нет событий</div>
<div class="empty-hint">Журнал событий пуст</div>
</div>
`;
} else {
data.events.forEach((e: Event, index: number) => {
const num = index + 1;
html += `<div>
<span class="event-num">#${num}</span>
<span class="event-time">[${e.time}]</span>
${e.event}
</div>`;
});
}
DOM.eventsContent.innerHTML = html;
}
// ============================================
// ПАГИНАЦИЯ ДЛЯ ДАННЫХ
// ============================================
function updateDataPagination(data: LogDataResponse): void {
const { page, total_pages, has_previous, has_next, start_index, end_index, total } = data;
if (DOM.dataPageInfo) {
if (total === 0) {
DOM.dataPageInfo.textContent = 'Нет записей';
} else {
DOM.dataPageInfo.textContent = `Страница ${page} из ${total_pages} (записи ${start_index}-${end_index} из ${total}) новые сверху`;
}
}
if (DOM.dataFirstPageBtn) DOM.dataFirstPageBtn.disabled = !has_previous;
if (DOM.dataPrevPageBtn) DOM.dataPrevPageBtn.disabled = !has_previous;
if (DOM.dataNextPageBtn) DOM.dataNextPageBtn.disabled = !has_next;
if (DOM.dataLastPageBtn) DOM.dataLastPageBtn.disabled = !has_next;
if (DOM.dataPageInput) {
DOM.dataPageInput.value = page.toString();
DOM.dataPageInput.max = total_pages.toString();
}
}
function goToDataPage(page: number): void {
if (page < 1) page = 1;
if (page > state.dataTotalPages) page = state.dataTotalPages;
if (page === state.dataPage) return;
if (!state.currentFile) return;
void loadFileData(state.currentFile, page);
}
function goToDataFirstPage(): void {
goToDataPage(1);
}
function goToDataLastPage(): void {
goToDataPage(state.dataTotalPages);
}
function goToDataPreviousPage(): void {
goToDataPage(state.dataPage - 1);
}
function goToDataNextPage(): void {
goToDataPage(state.dataPage + 1);
}
// ============================================
// ПАГИНАЦИЯ ДЛЯ СОБЫТИЙ
// ============================================
function updatePagination(data: EventsResponse): void {
const { page, total_pages, has_previous, has_next, start_index, end_index, total } = data;
if (DOM.pageInfo) {
if (total === 0) {
DOM.pageInfo.textContent = 'Нет записей';
} else {
DOM.pageInfo.textContent = `Страница ${page} из ${total_pages} (записи ${start_index}-${end_index} из ${total}) новые сверху`;
}
}
if (DOM.firstPageBtn) DOM.firstPageBtn.disabled = !has_previous;
if (DOM.prevPageBtn) DOM.prevPageBtn.disabled = !has_previous;
if (DOM.nextPageBtn) DOM.nextPageBtn.disabled = !has_next;
if (DOM.lastPageBtn) DOM.lastPageBtn.disabled = !has_next;
if (DOM.pageInput) {
DOM.pageInput.value = page.toString();
DOM.pageInput.max = total_pages.toString();
}
}
function goToPage(page: number): void {
if (page < 1) page = 1;
if (page > state.totalPages) page = state.totalPages;
if (page === state.currentPage) return;
void loadEvents(page);
}
function goToFirstPage(): void {
goToPage(1);
}
function goToLastPage(): void {
goToPage(state.totalPages);
}
function goToPreviousPage(): void {
goToPage(state.currentPage - 1);
}
function goToNextPage(): void {
goToPage(state.currentPage + 1);
}
// ============================================
// ОБНОВЛЕНИЕ ВРЕМЕНИ
// ============================================
async function updateTime(): Promise<void> {
try {
const response = await fetch('/api/health');
const data = await response.json();
if (DOM.serverTime) {
DOM.serverTime.textContent = data.server_time || new Date().toLocaleTimeString();
}
} catch (error) {
if (DOM.serverTime) {
DOM.serverTime.textContent = new Date().toLocaleTimeString();
}
}
}
// ============================================
// ИНИЦИАЛИЗАЦИЯ
// ============================================
function initDataPaginationControls(): void {
const dataTab = document.getElementById('tab-data');
if (!dataTab) return;
let paginationContainer = document.getElementById('dataPaginationContainer');
if (!paginationContainer) {
const tableContainer = document.getElementById('dataTableContainer');
if (!tableContainer) return;
paginationContainer = document.createElement('div');
paginationContainer.id = 'dataPaginationContainer';
paginationContainer.className = 'pagination-container';
paginationContainer.innerHTML = `
<div class="pagination-controls">
<button id="dataFirstPage" class="pagination-btn" title="Первая страница (самые новые)">⏮</button>
<button id="dataPrevPage" class="pagination-btn" title="Предыдущая страница (более новые)">◀</button>
<button id="dataNextPage" class="pagination-btn" title="Следующая страница (более старые)">▶</button>
<button id="dataLastPage" class="pagination-btn" title="Последняя страница (самые старые)">⏭</button>
<span class="pagination-goto">
<input type="number" id="dataPageInput" min="1" value="1" class="pagination-input">
<button id="dataGoToPage" class="pagination-btn pagination-goto-btn">Перейти</button>
</span>
<span id="dataPageInfo" class="pagination-info">Страница 1 из 1</span>
</div>
`;
dataTab.insertBefore(paginationContainer, tableContainer);
}
DOM.dataFirstPageBtn = document.getElementById('dataFirstPage') as HTMLButtonElement | null;
DOM.dataPrevPageBtn = document.getElementById('dataPrevPage') as HTMLButtonElement | null;
DOM.dataNextPageBtn = document.getElementById('dataNextPage') as HTMLButtonElement | null;
DOM.dataLastPageBtn = document.getElementById('dataLastPage') as HTMLButtonElement | null;
DOM.dataPageInfo = document.getElementById('dataPageInfo') as HTMLElement | null;
DOM.dataPageInput = document.getElementById('dataPageInput') as HTMLInputElement | null;
DOM.dataGoToPageBtn = document.getElementById('dataGoToPage') as HTMLButtonElement | null;
if (DOM.dataFirstPageBtn) DOM.dataFirstPageBtn.addEventListener('click', goToDataFirstPage);
if (DOM.dataPrevPageBtn) DOM.dataPrevPageBtn.addEventListener('click', goToDataPreviousPage);
if (DOM.dataNextPageBtn) DOM.dataNextPageBtn.addEventListener('click', goToDataNextPage);
if (DOM.dataLastPageBtn) DOM.dataLastPageBtn.addEventListener('click', goToDataLastPage);
if (DOM.dataGoToPageBtn) {
DOM.dataGoToPageBtn.addEventListener('click', () => {
if (DOM.dataPageInput) {
const page = parseInt(DOM.dataPageInput.value);
if (!isNaN(page) && page >= 1) {
goToDataPage(page);
}
}
});
}
if (DOM.dataPageInput) {
DOM.dataPageInput.addEventListener('keypress', (e: KeyboardEvent) => {
if (e.key === 'Enter' && DOM.dataGoToPageBtn) {
DOM.dataGoToPageBtn.click();
}
});
}
}
function initEventsPaginationControls(): void {
const eventsTab = document.getElementById('tab-events');
if (!eventsTab) return;
let paginationContainer = document.getElementById('eventsPaginationContainer');
if (!paginationContainer) {
const eventsContent = document.getElementById('eventsContent');
if (!eventsContent) return;
paginationContainer = document.createElement('div');
paginationContainer.id = 'eventsPaginationContainer';
paginationContainer.className = 'pagination-container';
paginationContainer.innerHTML = `
<div class="pagination-controls">
<button id="firstPage" class="pagination-btn" title="Первая страница (самые новые)">⏮</button>
<button id="prevPage" class="pagination-btn" title="Предыдущая страница (более новые)">◀</button>
<button id="nextPage" class="pagination-btn" title="Следующая страница (более старые)">▶</button>
<button id="lastPage" class="pagination-btn" title="Последняя страница (самые старые)">⏭</button>
<span class="pagination-goto">
<input type="number" id="pageInput" min="1" value="1" class="pagination-input">
<button id="goToPage" class="pagination-btn pagination-goto-btn">Перейти</button>
</span>
<span id="pageInfo" class="pagination-info">Страница 1 из 1</span>
</div>
`;
eventsTab.insertBefore(paginationContainer, eventsContent);
}
DOM.firstPageBtn = document.getElementById('firstPage') as HTMLButtonElement | null;
DOM.prevPageBtn = document.getElementById('prevPage') as HTMLButtonElement | null;
DOM.nextPageBtn = document.getElementById('nextPage') as HTMLButtonElement | null;
DOM.lastPageBtn = document.getElementById('lastPage') as HTMLButtonElement | null;
DOM.pageInfo = document.getElementById('pageInfo') as HTMLElement | null;
DOM.pageInput = document.getElementById('pageInput') as HTMLInputElement | null;
DOM.goToPageBtn = document.getElementById('goToPage') as HTMLButtonElement | null;
if (DOM.firstPageBtn) DOM.firstPageBtn.addEventListener('click', goToFirstPage);
if (DOM.prevPageBtn) DOM.prevPageBtn.addEventListener('click', goToPreviousPage);
if (DOM.nextPageBtn) DOM.nextPageBtn.addEventListener('click', goToNextPage);
if (DOM.lastPageBtn) DOM.lastPageBtn.addEventListener('click', goToLastPage);
if (DOM.goToPageBtn) {
DOM.goToPageBtn.addEventListener('click', () => {
if (DOM.pageInput) {
const page = parseInt(DOM.pageInput.value);
if (!isNaN(page) && page >= 1) {
goToPage(page);
}
}
});
}
if (DOM.pageInput) {
DOM.pageInput.addEventListener('keypress', (e: KeyboardEvent) => {
if (e.key === 'Enter' && DOM.goToPageBtn) {
DOM.goToPageBtn.click();
}
});
}
}
function initTabs(): void {
const tabs = document.querySelectorAll('.tab-btn[data-tab]');
tabs.forEach((btn) => {
btn.addEventListener('click', () => {
tabs.forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
const tabName = btn.getAttribute('data-tab');
const contents = document.querySelectorAll('.tab-content');
contents.forEach((el) => el.classList.remove('active'));
const targetTab = document.getElementById(`tab-${tabName}`);
if (targetTab) targetTab.classList.add('active');
// ===== УПРАВЛЕНИЕ БОКОВОЙ ПАНЕЛЬЮ =====
if (tabName === 'data') {
// Показываем боковую панель для вкладки "Данные"
toggleSidebar(true);
// Если данные ещё не загружены, загружаем
if (!state.currentData) {
const firstFile = document.querySelector('.log-file-item');
if (firstFile) {
const fileName = firstFile.querySelector('.file-name')?.textContent;
if (fileName) {
void loadFileData(fileName, 1);
}
}
}
} else if (tabName === 'events') {
// Скрываем боковую панель для вкладки "События"
toggleSidebar(false);
// Загружаем события, если их нет
if (state.currentEvents.length === 0) {
void loadEvents(1);
}
}
});
});
}
async function init(): Promise<void> {
console.log('[Logs] Инициализация...');
// По умолчанию показываем боковую панель
toggleSidebar(true);
initDataPaginationControls();
initEventsPaginationControls();
initTabs();
await loadFileList();
await updateTime();
setInterval(() => {
void updateTime();
}, 1000);
// Проверяем, какая вкладка активна при загрузке
const dataTab = document.getElementById('tab-data');
const eventsTab = document.getElementById('tab-events');
if (eventsTab && eventsTab.classList.contains('active')) {
toggleSidebar(false);
await loadEvents(1);
} else {
toggleSidebar(true);
}
console.log('[Logs] Инициализация завершена');
}
// ============================================
// ЗАПУСК
// ============================================
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
void init();
});
} else {
void init();
}