diff --git a/app.py b/app.py index 274399c..418b37e 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, HTTPException, File, UploadFile, Form, Query +from fastapi import FastAPI, HTTPException, File, UploadFile, Form, Query, WebSocket, WebSocketDisconnect from pydantic import BaseModel import sqlite3 import datetime @@ -10,6 +10,7 @@ import io import os from fastapi.middleware.cors import CORSMiddleware from werkzeug.utils import secure_filename +import json # Создаем два экземпляра FastAPI web_app = FastAPI(title="Web Interface") # Для веб-интерфейса (порт 8001) @@ -28,6 +29,9 @@ if 'protocol' not in columns: if 'note' not in columns: cursor.execute("ALTER TABLE installs ADD COLUMN note TEXT DEFAULT ''") conn.commit() +if 'last_seen' not in columns: + cursor.execute("ALTER TABLE installs ADD COLUMN last_seen TEXT DEFAULT NULL") + conn.commit() # Создаем таблицы cursor.execute(""" @@ -47,6 +51,7 @@ CREATE TABLE IF NOT EXISTS installs ( folder_id INTEGER, protocol TEXT DEFAULT 'rustdesk', note TEXT DEFAULT '', + last_seen TEXT DEFAULT NULL, FOREIGN KEY (folder_id) REFERENCES folders(id) ) """) @@ -110,6 +115,33 @@ web_app.mount("/icons", StaticFiles(directory="templates/icons"), name="icons") async def root(): return FileResponse("templates/index.html") +# WebSocket для отправки обновлений статуса +class ConnectionManager: + def __init__(self): + self.active_connections: list[WebSocket] = [] + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.append(websocket) + + def disconnect(self, websocket: WebSocket): + self.active_connections.remove(websocket) + + async def broadcast(self, message: str): + for connection in self.active_connections: + await connection.send_text(message) + +manager = ConnectionManager() + +@api_app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + await manager.connect(websocket) + try: + while True: + data = await websocket.receive_text() + except WebSocketDisconnect: + manager.disconnect(websocket) + # API-эндпоинты @api_app.get("/api/folders") def get_folders(): @@ -148,25 +180,44 @@ def delete_folder(folder_id: int): @api_app.get("/api/installs") def get_installs(): cursor.execute(""" - SELECT i.id, i.rust_id, i.computer_name, i.install_time, i.folder_id, f.name as folder_name, i.protocol, i.note + SELECT i.id, i.rust_id, i.computer_name, i.install_time, i.folder_id, f.name as folder_name, i.protocol, i.note, i.last_seen FROM installs i LEFT JOIN folders f ON i.folder_id = f.id """) rows = cursor.fetchall() return [{"id": row[0], "rust_id": row[1], "computer_name": row[2], "install_time": format_time(row[3]), - "folder_id": row[4], "folder_name": row[5], "protocol": row[6], "note": row[7]} + "folder_id": row[4], "folder_name": row[5], "protocol": row[6], "note": row[7], "last_seen": row[8]} for row in rows] @api_app.post("/api/install") -def add_install(data: InstallData): +async def add_install(data: InstallData): + rust_id = data.rust_id + computer_name = data.computer_name or f"PC_{rust_id}" install_time = data.install_time or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") folder_id = data.folder_id if data.folder_id is not None else unsorted_folder_id protocol = data.protocol or 'rustdesk' note = data.note or '' - cursor.execute("INSERT INTO installs (rust_id, computer_name, install_time, folder_id, protocol, note) VALUES (?, ?, ?, ?, ?, ?)", - (data.rust_id, data.computer_name, install_time, folder_id, protocol, note)) + last_seen = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + cursor.execute("SELECT id FROM installs WHERE rust_id = ?", (rust_id,)) + existing = cursor.fetchone() + if existing: + cursor.execute(""" + UPDATE installs + SET computer_name = ?, install_time = ?, folder_id = ?, protocol = ?, note = ?, last_seen = ? + WHERE rust_id = ? + """, (computer_name, install_time, folder_id, protocol, note, last_seen, rust_id)) + else: + cursor.execute(""" + INSERT INTO installs (rust_id, computer_name, install_time, folder_id, protocol, note, last_seen) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (rust_id, computer_name, install_time, folder_id, protocol, note, last_seen)) conn.commit() + + # Отправка обновления через WebSocket + installs = get_installs() + await manager.broadcast(json.dumps(installs)) return {"status": "success"} @api_app.put("/api/install/{install_id}") @@ -180,7 +231,6 @@ def update_install(install_id: int, data: InstallData): new_computer_name = data.computer_name if data.computer_name is not None else current[1] new_install_time = data.install_time if data.install_time is not None else current[2] new_folder_id = data.folder_id if data.folder_id is not None else current[3] - # Сохраняем текущее значение protocol, если новое не передано new_protocol = data.protocol if data.protocol is not None else current[4] new_note = data.note if data.note is not None else current[5] @@ -212,14 +262,14 @@ def delete_install(install_id: int): async def export_csv(folder_id: int | None = Query(None, description="ID папки для экспорта, если None - экспортировать все папки")): if folder_id: cursor.execute(""" - SELECT i.rust_id, i.computer_name, i.install_time, f.name as folder_name, i.protocol, i.note + SELECT i.rust_id, i.computer_name, i.install_time, f.name as folder_name, i.protocol, i.note, i.last_seen FROM installs i LEFT JOIN folders f ON i.folder_id = f.id WHERE i.folder_id = ? """, (folder_id,)) else: cursor.execute(""" - SELECT i.rust_id, i.computer_name, i.install_time, f.name as folder_name, i.protocol, i.note + SELECT i.rust_id, i.computer_name, i.install_time, f.name as folder_name, i.protocol, i.note, i.last_seen FROM installs i LEFT JOIN folders f ON i.folder_id = f.id """) @@ -227,10 +277,11 @@ async def export_csv(folder_id: int | None = Query(None, description="ID пап output = io.StringIO() writer = csv.writer(output, lineterminator='\n') - writer.writerow(['ID подключения', 'Имя компьютера', 'Время установки', 'Папка', 'Протокол', 'Заметка']) + writer.writerow(['ID подключения', 'Имя компьютера', 'Время установки', 'Папка', 'Протокол', 'Заметка', 'Последнее подключение']) for row in rows: install_time = format_time(row[2]) if row[2] else "" - writer.writerow([row[0], row[1], install_time, row[3], row[4], row[5]]) + last_seen = format_time(row[6]) if row[6] else "" + writer.writerow([row[0], row[1], install_time, row[3], row[4], row[5], last_seen]) headers = { 'Content-Disposition': 'attachment; filename="rustdesk_data.csv"', @@ -287,26 +338,21 @@ async def import_csv(file: UploadFile = File(...), folder_id: int | None = Query except Exception as e: raise HTTPException(status_code=400, detail=f"Ошибка импорта: {str(e)}") -# Новый эндпоинт для загрузки изображений @api_app.post("/api/upload-image") async def upload_image(install_id: int = Form(...), file: UploadFile = File(...)): if not file.filename: raise HTTPException(status_code=400, detail="No file provided") - # Проверка расширения файла allowed_extensions = {'png', 'jpg', 'jpeg', 'gif'} if not secure_filename(file.filename).rsplit('.', 1)[1].lower() in allowed_extensions: raise HTTPException(status_code=400, detail="Invalid file format. Use png, jpg, jpeg, or gif") - # Генерация безопасного имени файла filename = secure_filename(f"{install_id}_{file.filename}") file_path = os.path.join(UPLOAD_FOLDER, filename) - # Сохранение файла with open(file_path, "wb") as buffer: buffer.write(await file.read()) - # Возвращаем URL изображения url = f"/uploads/{filename}" return {"url": url} diff --git a/requirements.txt b/requirements.txt index c5bb776..95e0407 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ fastapi uvicorn python-multipart markdown -werkzeug \ No newline at end of file +werkzeug +websockets \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index bdfc0a3..58fc7a2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -8,320 +8,10 @@
@@ -407,6 +97,7 @@ let selectedInstallId = null; let sortField = null; let sortDirection = 'asc'; + let ws = null; const protocolIcons = { 'rustdesk': 'Нет результатов
'); } else { - $('#installs-list').html(installs.map(item => ` - - `).join('')); + $('#installs-list').html(installs.map(item => { + const lastSeen = new Date(item.last_seen || '1970-01-01'); + const isOnline = (new Date() - lastSeen) < 60000; // Онлайн, если менее 1 минуты + const statusClass = isOnline ? 'status-online' : 'status-offline'; + return ` + + `; + }).join('')); } updateSortArrows(); - // Восстанавливаем выделение после перерендера if (selectedInstallId) { $(`#installs-list .install-item[data-id="${selectedInstallId}"]`).addClass('selected'); } @@ -907,7 +618,7 @@ const install = allInstalls.find(i => i.id === installId); if (install) { data['protocol'] = install.protocol; - data['note'] = install.note; // Сохраняем текущую заметку + data['note'] = install.note; } $.ajax({ url: `${API_URL}/install/${installId}`, @@ -947,7 +658,6 @@ console.error('Install not found:', installId); return; } - // Передаем все текущие данные записи, чтобы ничего не очистилось $.ajax({ url: `${API_URL}/install/${installId}`, type: 'PUT', @@ -958,7 +668,7 @@ install_time: install.install_time, folder_id: folderId, protocol: install.protocol, - note: install.note // Явно передаем заметку + note: install.note }), success: function () { console.log('Move successful'); @@ -1008,7 +718,7 @@ function openNoteModal(installId, currentNote) { console.log('Opening note modal for installId:', installId, 'with note:', currentNote); - selectedInstallId = installId; // Сохраняем выделение + selectedInstallId = installId; $('#note-textarea').val(currentNote || ''); $('#note-modal').show(); $('#modal-overlay').show(); @@ -1063,7 +773,7 @@ const note = $('#note-textarea').val() || ''; const install = allInstalls.find(i => i.id === selectedInstallId); if (install) { - const currentSelectedId = selectedInstallId; // Сохраняем текущий selectedInstallId + const currentSelectedId = selectedInstallId; $.ajax({ url: `${API_URL}/install/${selectedInstallId}`, type: 'PUT', @@ -1078,11 +788,9 @@ }), success: function () { console.log('Note saved successfully'); - loadInstalls(selectedFolderId); // Перезагружаем список - selectedInstallId = currentSelectedId; // Восстанавливаем selectedInstallId - closeNoteModal(); // Закрываем модальное окно - // Восстанавливаем выделение - const items = $('#installs-list .install-item'); + loadInstalls(selectedFolderId); + selectedInstallId = currentSelectedId; + closeNoteModal(); $(`#installs-list .install-item[data-id="${selectedInstallId}"]`).addClass('selected'); updateNotesPanel(); }, @@ -1099,19 +807,12 @@ $('#modal-overlay').hide(); $('#note-textarea').val(''); document.getElementById('image-upload-input').value = ''; - // Фокус остаётся на текущей записи if (selectedInstallId) { - const items = $('#installs-list .install-item'); $(`#installs-list .install-item[data-id="${selectedInstallId}"]`).addClass('selected'); updateNotesPanel(); } } - function closeAddModal() { - $('#add-modal').hide(); - $('#modal-overlay').hide(); - } - function updateNotesPanel() { if (selectedInstallId) { const install = allInstalls.find(i => i.id === selectedInstallId);