v 0.0.63 (перефиксация)

This commit is contained in:
DND
2026-06-08 12:36:31 +07:00
parent f1cfcd897c
commit 95245597a4
16 changed files with 681 additions and 653 deletions
Executable → Regular
BIN
View File
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
{"id":29,"timestamp":"2026-06-08T11:58:24.845455261+07:00","level":"INFO","category":"RECORDING","message":"Запись: НАЧАЛО | Канал: channel1 | Файл: 2026-06-08 11-58-24.wav"}
{"id":30,"timestamp":"2026-06-08T11:58:33.898175566+07:00","level":"INFO","category":"RECORDING","message":"Запись: ЗАВЕРШЕНА | Канал: channel1 | Файл: 2026-06-08 11-58-24.wav | Длительность: 1м 0с | Размер: 54 KB"}
{"id":31,"timestamp":"2026-06-08T11:58:33.901436615+07:00","level":"INFO","category":"SMB","message":"Добавлен в очередь повторных попыток | Файл: 2026-06-08 11-58-24.wav | Папка: 1-60рпропол-998-2026"}
{"id":32,"timestamp":"2026-06-08T11:59:15.005753566+07:00","level":"INFO","category":"SMB","message":"Повторная попытка отправки (попытка \u0001) | Файл: 2026-06-08 11-58-24.wav"}
{"id":33,"timestamp":"2026-06-08T11:59:15.077702634+07:00","level":"INFO","category":"SMB","message":"SMB отправка: УСПЕШНО | Файл: 2026-06-08 11-58-24.wav"}
{"id":34,"timestamp":"2026-06-08T11:59:15.093297799+07:00","level":"INFO","category":"SMB","message":"SMB отправка: УСПЕШНО | Файл: 2026-06-08 11-58-24.wav"}
+51 -16
View File
@@ -12,6 +12,7 @@ import (
"audio-recorder/database"
"audio-recorder/devices"
"audio-recorder/logger"
"audio-recorder/recorder"
"audio-recorder/server"
"audio-recorder/smb"
@@ -21,13 +22,19 @@ import (
var staticFiles embed.FS
func main() {
// Инициализация логгера
logger.Init()
// Инициализация базы данных
db := database.GetDB()
defer db.Close()
// Инициализация настроек по умолчанию
if err := db.InitDefaultSettings(); err != nil {
log.Printf("Warning: Failed to init default settings: %v", err)
}
// Получаем папку для записей из БД
recordingsDir, err := db.GetSetting("recordings_dir")
if err != nil || recordingsDir == "" {
recordingsDir = "recordings"
@@ -36,17 +43,20 @@ func main() {
}
}
// Создаем папку для записей
if err := os.MkdirAll(recordingsDir, 0755); err != nil {
log.Fatal("Не удалось создать папку для записей:", err)
}
log.Printf("Папка для записей: %s", recordingsDir)
// Инициализируем устройства при старте
dm := devices.GetManager()
log.Printf("Сканирование устройств при старте...")
devicesList := dm.ScanDevices()
log.Printf("Найдено устройств: %d", len(devicesList))
// Сохраняем устройства в БД
for _, dev := range devicesList {
status := "✓"
if !dev.Working {
@@ -68,8 +78,10 @@ func main() {
}
}
// Создаем рекордер
rec := recorder.NewRecorder(recordingsDir)
// Проверяем, есть ли каналы в БД, если нет - создаем каналы по умолчанию
channels, err := db.GetAllChannels()
if err != nil {
log.Printf("Warning: Failed to get channels from DB: %v", err)
@@ -78,9 +90,11 @@ func main() {
if len(channels) == 0 {
log.Println("No channels in database, creating default channels...")
// Создаем папки для каналов
os.MkdirAll(filepath.Join(recordingsDir, "channel1"), 0755)
os.MkdirAll(filepath.Join(recordingsDir, "channel2"), 0755)
// Канал 1
channel1 := recorder.NewChannel(
"channel1",
"Канал 1",
@@ -95,6 +109,7 @@ func main() {
channel1.SMBFolder = "channel1"
rec.AddChannel(channel1)
// Канал 2
channel2 := recorder.NewChannel(
"channel2",
"Канал 2",
@@ -112,19 +127,26 @@ func main() {
log.Println("Default channels created successfully")
}
// Устанавливаем экземпляр рекордера в обработчики
server.SetRecorder(rec)
// Устанавливаем экземпляры для обратных вызовов
recorder.SetRecorderInstance(rec)
recorder.SetDeviceManager(dm)
restoreChannelsState(rec, dm)
// Восстанавливаем состояние каналов после перезагрузки
restoreChannelsState(rec, dm, db)
// Запускаем проверяльщик состояний
stateChecker := recorder.GetStateChecker()
stateChecker.Start()
// Настройка SMB клиента
smbClient := smb.GetSMBClient()
smbEnabled, _ := db.GetSetting("smb_enabled")
smbAutoStart, _ := db.GetSetting("smb_auto_start")
// Загружаем настройки SMB из БД
smbEnabledStr, _ := db.GetSetting("smb_enabled")
smbAutoStartStr, _ := db.GetSetting("smb_auto_start")
smbServer, _ := db.GetSetting("smb_server")
smbPortStr, _ := db.GetSetting("smb_port")
smbShare, _ := db.GetSetting("smb_share")
@@ -140,9 +162,10 @@ func main() {
}
}
if err := smbClient.UpdateConfig(smb.SMBConfig{
Enabled: smbEnabled == "true",
AutoStart: smbAutoStart == "true",
// Применяем настройки SMB
smbConfig := smb.SMBConfig{
Enabled: smbEnabledStr == "true",
AutoStart: smbAutoStartStr == "true",
Server: smbServer,
Port: smbPort,
Share: smbShare,
@@ -150,39 +173,45 @@ func main() {
Username: smbUsername,
Password: smbPassword,
BasePath: smbBasePath,
}); err != nil {
}
if err := smbClient.UpdateConfig(smbConfig); err != nil {
log.Printf("Warning: Failed to update SMB config: %v", err)
}
// Устанавливаем колбэк для отправки файлов на SMB
recorder.SetRecordingCompletedCallback(func(filename string, filePath string, smbFolder string) {
log.Printf("Запись завершена: %s, отправка в SMB папку: %s", filename, smbFolder)
if smbClient.IsConnected() {
log.Printf("SMB client connected, uploading...")
if err := smbClient.UploadFile(filePath, smbFolder, filename); err != nil {
log.Printf("Не удалось загрузить файл на SMB: %v, будет повторная попытка", err)
log.Printf("Не удалось загрузить файл на SMB: %v", err)
smbClient.AddToRetry(filePath, smbFolder, filename)
} else {
log.Printf("✅ Файл успешно загружен на SMB: %s", filename)
if err := os.Remove(filePath); err != nil {
log.Printf("Не удалось удалить локальный файл: %v", err)
} else {
log.Printf("Локальный файл удален после успешной загрузки: %s", filePath)
log.Printf("Локальный файл удален: %s", filePath)
}
}
} else {
log.Printf("SMB клиент не подключен, добавление в очередь повторных попыток")
log.Printf("⚠️ SMB клиент не подключен")
smbClient.AddToRetry(filePath, smbFolder, filename)
}
})
if smbEnabled == "true" && smbAutoStart == "true" {
log.Printf("Запуск SMB клиента при автозапуске...")
// Запускаем SMB клиент если включен
if smbEnabledStr == "true" {
log.Printf("Запуск SMB клиента...")
if err := smbClient.Start(); err != nil {
log.Printf("Предупреждение: Не удалось запустить SMB клиент: %v", err)
}
}
// Запускаем обработчик очереди повторных отправок
go smbClient.ProcessRetryQueue()
// Настраиваем обработку сигналов для graceful shutdown
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
@@ -190,14 +219,20 @@ func main() {
<-signalChan
log.Println("Получен сигнал завершения, сохраняем состояния каналов...")
// Останавливаем проверяльщик состояний
stateChecker.Stop()
// Останавливаем SMB клиент
smbClient.Stop()
// Сохраняем состояния всех каналов в БД
rec.SaveAllStates()
log.Println("Завершаем работу...")
os.Exit(0)
}()
// Настраиваем и запускаем HTTP сервер
srv := server.NewServer(staticFiles, recordingsDir)
srv.SetupRoutes()
@@ -208,14 +243,14 @@ func main() {
log.Printf("🔄 Проверка состояния каналов: каждые %d секунд", stateChecker.GetCheckInterval())
log.Printf("")
// Запускаем сервер с graceful shutdown
if err := srv.Run(); err != nil {
log.Fatal("Server error:", err)
}
}
// restoreChannelsState восстанавливает состояние каналов после перезагрузки
func restoreChannelsState(rec *recorder.Recorder, dm *devices.DeviceManager) {
db := database.GetDB()
func restoreChannelsState(rec *recorder.Recorder, dm *devices.DeviceManager, db *database.DB) {
channels := rec.GetAllChannels()
log.Printf("=== Восстановление состояния каналов из БД ===")
+36 -5
View File
@@ -350,50 +350,81 @@ func HandleChangePassword(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Получаем username из заголовка, установленного AuthMiddleware
username := r.Header.Get("X-Username")
if username == "" {
// Пробуем получить из сессии
cookie, err := r.Cookie("session_token")
if err == nil {
sessionsLock.RLock()
session, exists := sessions[cookie.Value]
sessionsLock.RUnlock()
if exists {
username = session.Username
}
}
}
if username == "" {
sendJSONError(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSONError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.OldPassword == "" || req.NewPassword == "" {
sendJSONError(w, "Old and new passwords are required", http.StatusBadRequest)
return
}
if len(req.NewPassword) < 4 {
sendJSONError(w, "Password must be at least 4 characters", http.StatusBadRequest)
return
}
usersLock.RLock()
user, exists := users[username]
usersLock.RUnlock()
if !exists {
sendJSONError(w, "User not found", http.StatusNotFound)
return
}
if !verifyPassword(req.OldPassword, user.PasswordHash) {
sendJSONError(w, "Invalid old password", http.StatusUnauthorized)
log.Printf("Failed password change attempt for user: %s from %s", username, r.RemoteAddr)
return
}
if !isStrongPassword(req.NewPassword) {
sendJSONError(w, fmt.Sprintf("Password must be at least %d characters and contain uppercase, lowercase, and numbers", minPasswordLength), http.StatusBadRequest)
return
}
newHash, err := hashPassword(req.NewPassword)
if err != nil {
sendJSONError(w, "Error processing password", http.StatusInternalServerError)
return
}
usersLock.Lock()
user.PasswordHash = newHash
users[username] = user
usersLock.Unlock()
// Инвалидируем все сессии пользователя кроме текущей
invalidateUserSessionsExceptCurrent(username, r)
sendJSONResponse(w, http.StatusOK, map[string]string{"status": "password_changed", "message": "Password successfully changed"})
sendJSONResponse(w, http.StatusOK, map[string]string{
"status": "password_changed",
"message": "Password successfully changed",
})
log.Printf("Password changed for user: %s from %s", username, r.RemoteAddr)
}
+54 -357
View File
@@ -21,6 +21,7 @@ import (
"audio-recorder/database"
"audio-recorder/devices"
"audio-recorder/logger"
"audio-recorder/recorder"
"audio-recorder/smb"
)
@@ -230,35 +231,8 @@ func HandleWebSocket(w http.ResponseWriter, r *http.Request) {
statusTicker := time.NewTicker(1 * time.Second)
defer statusTicker.Stop()
waveformTicker := time.NewTicker(50 * time.Millisecond)
defer waveformTicker.Stop()
for {
select {
case <-waveformTicker.C:
ch, exists := RecorderInstance.GetChannel(channelID)
if !exists {
log.Printf("Channel %s no longer exists, closing WebSocket", channelID)
return
}
audioData := ch.GetAudioData()
samples := recorder.GetAudioSamples(audioData, 400)
if err := safeWriteJSON(conn, map[string]interface{}{
"type": "waveform",
"samples": samples,
"channel": channelID,
"recording": ch.IsRecording,
"timestamp": time.Now().Unix(),
"sample_rate": ch.SampleRate,
}); err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
log.Printf("WebSocket write error for user %s: %v", username, err)
}
return
}
case <-statusTicker.C:
ch, exists := RecorderInstance.GetChannel(channelID)
if !exists {
@@ -633,26 +607,14 @@ func HandleRecordings(w http.ResponseWriter, r *http.Request) {
}
fullPath := filepath.Join(channelDir, file.Name())
// Получаем длительность
durationSeconds := GetWavDuration(fullPath)
// Проверяем на нереалистичные значения (больше 1 часа для файла меньше 100MB)
if durationSeconds > 3600 && info.Size() < 100*1024*1024 {
// Слишком большая длительность для такого размера файла - пересчитываем
if durationSeconds <= 0 && info.Size() > 0 {
estimatedBytesPerSecond := 32000
durationSeconds = int(info.Size() / int64(estimatedBytesPerSecond))
if durationSeconds < 1 {
if durationSeconds < 1 && info.Size() > 1000 {
durationSeconds = 1
}
if durationSeconds > 3600 {
durationSeconds = 3600
}
}
// Ограничиваем максимальную длительность 24 часами
if durationSeconds > 86400 {
durationSeconds = 0
}
smbSent := false
@@ -719,6 +681,9 @@ func HandleDeleteRecordings(w http.ResponseWriter, r *http.Request) {
errors = append(errors, fmt.Sprintf("Failed to delete %s: %v", filename, err))
} else {
deletedCount++
// Логируем удаление файла
logger.Add("INFO", "RECORDING", "Удаление записи | Канал: "+file.ChannelID+" | Файл: "+filename)
recordings, _ := db.GetRecordingsByChannel(file.ChannelID, 1000)
for _, rec := range recordings {
if rec.Filename == filename {
@@ -849,8 +814,11 @@ func HandleGetConfig(w http.ResponseWriter, r *http.Request) {
return
}
config := ch.GetConfig()
log.Printf("GetConfig for channel %s: device=%s, name=%s", channelID, config.Device, config.ChannelName)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ch.GetConfig())
json.NewEncoder(w).Encode(config)
}
// HandleDebugState - отладочный эндпоинт
@@ -910,27 +878,21 @@ func GetWavDuration(filepath string) int {
return 0
}
// Читаем заголовок
header := make([]byte, 44)
if _, err := file.Read(header); err != nil {
return 0
}
// Проверяем RIFF и WAVE
if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" {
return 0
}
// Получаем параметры из fmt чанка (стандартная позиция 12-36)
// Проверяем что fmt чанк на месте
if string(header[12:16]) == "fmt " {
channels := int(header[22]) | int(header[23])<<8
sampleRate := int(header[24]) | int(header[25])<<8 | int(header[26])<<16 | int(header[27])<<24
bitsPerSample := int(header[34]) | int(header[35])<<8
// Пытаемся найти реальный размер data чанка
var dataSize int64
offset := int64(12)
for offset+8 <= fileSize {
chunkHeader := make([]byte, 8)
@@ -958,328 +920,22 @@ func GetWavDuration(filepath string) int {
}
}
// Если не удалось прочитать из заголовка, вычисляем по размеру файла
dataSizeActual := fileSize - 44
if dataSizeActual <= 0 {
return 0
}
// Определяем по размеру файла
if dataSizeActual < 1000000 {
// Маленький файл - скорее всего 8kHz, 8bit, mono (8000 байт/сек)
return int(dataSizeActual / 8000)
} else if dataSizeActual < 3000000 {
// Средний файл - 16kHz, 16bit, mono (32000 байт/сек)
return int(dataSizeActual / 32000)
} else if dataSizeActual < 6000000 {
// 22kHz, 16bit, mono (44000 байт/сек)
return int(dataSizeActual / 44000)
} else {
// Большой файл - 48kHz, 16bit, stereo (192000 байт/сек)
return int(dataSizeActual / 192000)
}
}
// FixWavHeader исправляет заголовок WAV файла
func FixWavHeader(filepath string) error {
file, err := os.OpenFile(filepath, os.O_RDWR, 0644)
if err != nil {
return err
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
return err
}
fileSize := fileInfo.Size()
// Читаем заголовок
header := make([]byte, 44)
if _, err := file.ReadAt(header, 0); err != nil {
return err
}
// Проверяем что это WAV
if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" {
return nil // Не WAV файл
}
// Ищем data чанк
var dataOffset int64
var dataSize int64
offset := int64(12)
for offset+8 <= fileSize {
chunkHeader := make([]byte, 8)
if _, err := file.ReadAt(chunkHeader, offset); err != nil {
break
}
chunkID := string(chunkHeader[0:4])
chunkSize := int64(chunkHeader[4]) | int64(chunkHeader[5])<<8 | int64(chunkHeader[6])<<16 | int64(chunkHeader[7])<<24
if chunkID == "data" {
dataOffset = offset + 8
dataSize = chunkSize
break
}
offset += 8 + chunkSize
}
if dataSize > 0 && dataSize != fileSize-dataOffset {
// Исправляем размер data чанка
correctDataSize := fileSize - dataOffset
log.Printf("Fixing WAV header for %s: dataSize %d -> %d", filepath, dataSize, correctDataSize)
// Записываем правильный размер data чанка
dataSizeBytes := make([]byte, 4)
binary.LittleEndian.PutUint32(dataSizeBytes, uint32(correctDataSize))
file.WriteAt(dataSizeBytes, dataOffset-4)
// Исправляем общий размер RIFF чанка
riffSize := fileSize - 8
riffSizeBytes := make([]byte, 4)
binary.LittleEndian.PutUint32(riffSizeBytes, uint32(riffSize))
file.WriteAt(riffSizeBytes, 4)
}
return nil
}
// estimateDurationFromSize оценивает длительность по размеру файла
func estimateDurationFromSize(fileSize int64) int {
if fileSize <= 44 {
return 0
}
// Пробуем разные варианты параметров
// 1. 16kHz, 16bit, mono = 32000 байт/сек
duration1 := int((fileSize - 44) / 32000)
// 2. 16kHz, 16bit, stereo = 64000 байт/сек
duration2 := int((fileSize - 44) / 64000)
// 3. 8kHz, 8bit, mono = 8000 байт/сек
duration3 := int((fileSize - 44) / 8000)
// 4. 44.1kHz, 16bit, stereo = 176400 байт/сек
duration4 := int((fileSize - 44) / 176400)
log.Printf("[WAV] Estimated durations: mono16=%d, stereo16=%d, mono8=%d, cd=%d",
duration1, duration2, duration3, duration4)
// Выбираем наиболее вероятную длительность
if duration1 > 0 && duration1 <= 3600 {
return duration1
}
if duration2 > 0 && duration2 <= 3600 {
return duration2
}
if duration3 > 0 && duration3 <= 3600 {
return duration3
}
if duration4 > 0 && duration4 <= 3600 {
return duration4
}
// Если всё плохо, возвращаем 0
return 0
}
// getWavDurationBySearch ищет fmt и data чанки во всем файле
func getWavDurationBySearch(file *os.File, fileSize int64) int {
// Сбрасываем указатель на начало
file.Seek(12, 0)
var sampleRate uint32
var channels uint16
var bitsPerSample uint16
var dataSize uint32
for {
// Читаем заголовок чанка
chunkHeader := make([]byte, 8)
if _, err := file.Read(chunkHeader); err != nil {
break
}
chunkID := string(chunkHeader[0:4])
chunkSize := binary.LittleEndian.Uint32(chunkHeader[4:8])
// Защита от слишком больших чанков
if chunkSize > uint32(fileSize) {
break
}
if chunkID == "fmt " {
// Читаем fmt данные
fmtData := make([]byte, chunkSize)
if _, err := file.Read(fmtData); err != nil {
break
}
if len(fmtData) >= 16 {
audioFormat := binary.LittleEndian.Uint16(fmtData[0:2])
if audioFormat == 1 { // PCM
channels = binary.LittleEndian.Uint16(fmtData[2:4])
sampleRate = binary.LittleEndian.Uint32(fmtData[4:8])
bitsPerSample = binary.LittleEndian.Uint16(fmtData[14:16])
}
}
} else if chunkID == "data" {
dataSize = chunkSize
// Если нашли data чанк и уже есть параметры, можно выйти
if sampleRate > 0 && channels > 0 && bitsPerSample > 0 {
break
}
// Если нет параметров, продолжаем поиск
} else {
// Пропускаем чанк
file.Seek(int64(chunkSize), 1)
}
// Проверяем позицию
pos, _ := file.Seek(0, 1)
if pos >= fileSize {
break
}
}
if dataSize == 0 {
return 0
}
if sampleRate == 0 || channels == 0 || bitsPerSample == 0 {
return 0
}
bytesPerSecond := uint32(sampleRate) * uint32(channels) * uint32(bitsPerSample/8)
if bytesPerSecond == 0 {
return 0
}
return int(dataSize / bytesPerSecond)
}
// findDataChunk находит размер data чанка
func findDataChunk(file *os.File, fileSize int64) uint32 {
file.Seek(12, 0)
for {
chunkHeader := make([]byte, 8)
if _, err := file.Read(chunkHeader); err != nil {
break
}
chunkID := string(chunkHeader[0:4])
chunkSize := binary.LittleEndian.Uint32(chunkHeader[4:8])
if chunkID == "data" {
return chunkSize
}
// Пропускаем чанк
file.Seek(int64(chunkSize), 1)
pos, _ := file.Seek(0, 1)
if pos >= fileSize {
break
}
}
return 0
}
// parseWavDurationBySearching ищет fmt и data чанки во всем файле
func parseWavDurationBySearching(file *os.File, fileSize int64) int {
// Ищем fmt чанк
file.Seek(12, 0)
for {
chunkHeader := make([]byte, 8)
if _, err := file.Read(chunkHeader); err != nil {
break
}
chunkID := string(chunkHeader[0:4])
chunkSize := int(chunkHeader[4]) | int(chunkHeader[5])<<8 | int(chunkHeader[6])<<16 | int(chunkHeader[7])<<24
if chunkID == "fmt " && chunkSize >= 16 {
// Читаем fmt данные
fmtData := make([]byte, chunkSize)
if _, err := file.Read(fmtData); err != nil {
break
}
audioFormat := int(fmtData[0]) | int(fmtData[1])<<8
if audioFormat == 1 { // PCM
channels := int(fmtData[2]) | int(fmtData[3])<<8
sampleRate := int(fmtData[4]) | int(fmtData[5])<<8 | int(fmtData[6])<<16 | int(fmtData[7])<<24
bitsPerSample := int(fmtData[14]) | int(fmtData[15])<<8
// Теперь ищем data чанк
dataSize := findDataChunkSize(file, fileSize)
if dataSize > 0 && sampleRate > 0 && channels > 0 && bitsPerSample > 0 {
bytesPerSample := bitsPerSample / 8
if bytesPerSample > 0 {
duration := dataSize / (sampleRate * channels * bytesPerSample)
if duration > 0 && duration < 86400 {
return duration
}
}
}
break
}
} else {
// Пропускаем чанк
file.Seek(int64(chunkSize), 1)
}
// Защита от бесконечного цикла
pos, _ := file.Seek(0, 1)
if pos >= fileSize {
break
}
}
return estimateDurationFromFileSize(fileSize)
}
// findDataChunkSize находит размер data чанка
func findDataChunkSize(file *os.File, fileSize int64) int {
file.Seek(12, 0)
for {
chunkHeader := make([]byte, 8)
if _, err := file.Read(chunkHeader); err != nil {
break
}
chunkID := string(chunkHeader[0:4])
chunkSize := int(chunkHeader[4]) | int(chunkHeader[5])<<8 | int(chunkHeader[6])<<16 | int(chunkHeader[7])<<24
if chunkID == "data" {
return chunkSize
}
// Пропускаем чанк
file.Seek(int64(chunkSize), 1)
// Защита от бесконечного цикла
pos, _ := file.Seek(0, 1)
if pos >= fileSize {
break
}
}
return 0
}
// estimateDurationFromFileSize оценивает длительность по размеру файла
func estimateDurationFromFileSize(fileSize int64) int {
if fileSize <= 44 {
return 0
}
// Предполагаем стандартные параметры: 16000 Hz, 16 бит, 1 канал = 32000 байт/сек
dataSize := fileSize - 44
duration := int(dataSize / 32000)
if duration < 1 && dataSize > 1000 {
duration = 1
}
log.Printf("estimateDurationFromFileSize: fileSize=%d, estimated duration=%d seconds", fileSize, duration)
return duration
}
// HandleStorageSettings - обработчик для настроек хранилища
func HandleStorageSettings(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
@@ -1353,8 +1009,11 @@ func HandleLiveStream(w http.ResponseWriter, r *http.Request) {
channelID = "channel1"
}
log.Printf("Live stream request for channel %s", channelID)
ch, exists := RecorderInstance.GetChannel(channelID)
if !exists {
log.Printf("Channel %s not found", channelID)
sendJSONError(w, "Channel not found", http.StatusNotFound)
return
}
@@ -1379,6 +1038,7 @@ func HandleLiveStream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "audio/wav")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
header := make([]byte, 44)
copy(header[0:4], []byte("RIFF"))
@@ -1394,7 +1054,7 @@ func HandleLiveStream(w http.ResponseWriter, r *http.Request) {
binary.LittleEndian.PutUint16(header[32:34], blockAlign)
binary.LittleEndian.PutUint16(header[34:36], 16)
copy(header[36:40], []byte("data"))
binary.LittleEndian.PutUint32(header[40:44], 0)
binary.LittleEndian.PutUint32(header[40:44], 0xFFFFFFFF)
if _, err := w.Write(header); err != nil {
log.Printf("Failed to write WAV header: %v", err)
@@ -1418,7 +1078,7 @@ func HandleLiveStream(w http.ResponseWriter, r *http.Request) {
}
cmd := exec.Command("arecord", args...)
log.Printf("Live stream command: %v", cmd.Args)
log.Printf("Live stream command: arecord %v", args)
stdout, err := cmd.StdoutPipe()
if err != nil {
@@ -1450,7 +1110,7 @@ func HandleLiveStream(w http.ResponseWriter, r *http.Request) {
}
}()
buffer := make([]byte, 4096)
buffer := make([]byte, 8192)
done := make(chan struct{})
go func() {
@@ -1672,6 +1332,7 @@ func HandleChannelSchedule(w http.ResponseWriter, r *http.Request) {
}
}
logger.AddSettings(req.ChannelID, "расписание", "обновлено")
sendJSONResponse(w, http.StatusOK, map[string]string{"status": "ok"})
case http.MethodDelete:
@@ -1809,3 +1470,39 @@ func HandleFTPStop(w http.ResponseWriter, r *http.Request) {
"running": ftp.Running,
})
}
// HandleGetLogs - получение логов
func HandleGetLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
limit := 500
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}
category := r.URL.Query().Get("category")
var logs interface{}
if category != "" {
logs = logger.GetLogsByCategory(category, limit)
} else {
logs = logger.GetLogs(limit)
}
sendJSONResponse(w, http.StatusOK, logs)
}
// HandleClearLogs - очистка логов
func HandleClearLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
logger.ClearLogs()
sendJSONResponse(w, http.StatusOK, map[string]string{"status": "ok"})
}
+4
View File
@@ -49,6 +49,10 @@ func (s *Server) SetupRoutes() {
// ========== Live Stream ==========
mux.HandleFunc("/api/live-stream", CORSMiddleware(AuthMiddleware(HandleLiveStream)))
// ========== Логи ==========
mux.HandleFunc("/api/logs", CORSMiddleware(AuthMiddleware(HandleGetLogs)))
mux.HandleFunc("/api/logs/clear", CORSMiddleware(AuthMiddleware(HandleClearLogs)))
// ========== Статические файлы ==========
mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/static/")
+49 -19
View File
@@ -13,6 +13,7 @@ import (
"github.com/hirochachacha/go-smb2"
"audio-recorder/database"
"audio-recorder/logger"
)
type SMBClient struct {
@@ -66,7 +67,6 @@ func GetSMBClient() *SMBClient {
return smbInstance
}
// formatAddress форматирует адрес с поддержкой IPv6
func formatAddress(host string, port int) string {
if strings.Contains(host, ":") && !strings.Contains(host, "[") {
return fmt.Sprintf("[%s]:%d", host, port)
@@ -84,18 +84,29 @@ func (s *SMBClient) Start() error {
}
if s.connected {
log.Printf("SMB client already connected")
return nil
}
if s.Server == "" || s.Username == "" || s.Password == "" {
log.Printf("SMB client not configured properly, skipping start")
return nil
if s.Server == "" {
log.Printf("SMB server not configured, skipping start")
return fmt.Errorf("SMB server not configured")
}
if s.Username == "" {
log.Printf("SMB username not configured, skipping start")
return fmt.Errorf("SMB username not configured")
}
if s.Share == "" {
log.Printf("SMB share not configured, skipping start")
return fmt.Errorf("SMB share not configured")
}
address := formatAddress(s.Server, s.Port)
log.Printf("Starting SMB client, connecting to %s", address)
log.Printf("Starting SMB client, connecting to %s, share: %s", address, s.Share)
conn, err := net.Dial("tcp", address)
conn, err := net.DialTimeout("tcp", address, 10*time.Second)
if err != nil {
log.Printf("Failed to connect to SMB server: %v", err)
return err
@@ -118,7 +129,7 @@ func (s *SMBClient) Start() error {
s.session = session
s.connected = true
log.Printf("✅ SMB client connected successfully to %s", address)
log.Printf("✅ SMB client connected successfully to %s, share: %s", address, s.Share)
return nil
}
@@ -179,14 +190,17 @@ func (s *SMBClient) UpdateConfig(config SMBConfig) error {
if config.Password != "" && config.Password != "********" {
s.Password = config.Password
}
s.Share = config.Share
s.Workgroup = config.Workgroup
s.BasePath = config.BasePath
s.AutoStart = config.AutoStart
if config.Share != "" {
s.Share = config.Share
}
if config.Workgroup != "" {
s.Workgroup = config.Workgroup
}
if config.Username != "" {
s.Username = config.Username
}
s.BasePath = config.BasePath
s.AutoStart = config.AutoStart
if wasConnected && (wasEnabled != s.Enabled || config.Password != "********") {
go func() {
@@ -216,13 +230,14 @@ func (s *SMBClient) UploadFile(localPath string, remoteFolder string, remoteFile
if _, err := os.Stat(localPath); os.IsNotExist(err) {
log.Printf("File does not exist: %s", localPath)
s.updateRecordingStatus(localPath, false, "file does not exist")
return fmt.Errorf("file does not exist: %s", localPath)
}
address := formatAddress(s.Server, s.Port)
log.Printf("Connecting to %s for upload", address)
log.Printf("Connecting to %s for upload, share: %s", address, s.Share)
conn, err := net.Dial("tcp", address)
conn, err := net.DialTimeout("tcp", address, 10*time.Second)
if err != nil {
log.Printf("Failed to connect for upload: %v", err)
s.updateRecordingStatus(localPath, false, err.Error())
@@ -245,10 +260,11 @@ func (s *SMBClient) UploadFile(localPath string, remoteFolder string, remoteFile
}
defer session.Logoff()
log.Printf("Mounting share: %s", s.Share)
fs, err := session.Mount(s.Share)
if err != nil {
log.Printf("Failed to mount share: %v", err)
s.updateRecordingStatus(localPath, false, err.Error())
log.Printf("Failed to mount share '%s': %v", s.Share, err)
s.updateRecordingStatus(localPath, false, fmt.Sprintf("Mount failed: %v", err))
return err
}
@@ -268,11 +284,15 @@ func (s *SMBClient) UploadFile(localPath string, remoteFolder string, remoteFile
}
currentPath = filepath.Join(currentPath, dir)
currentPath = strings.ReplaceAll(currentPath, "\\", "/")
_, err := fs.Stat(currentPath)
if err != nil {
log.Printf("Creating directory: %s", currentPath)
err = fs.Mkdir(currentPath, 0755)
if err != nil {
log.Printf("Failed to create directory %s: %v", currentPath, err)
s.updateRecordingStatus(localPath, false, err.Error())
return err
}
}
}
@@ -335,6 +355,7 @@ func (s *SMBClient) AddToRetry(localPath, remoteFolder, filename string) {
Attempts: 0,
}
log.Printf("File added to retry queue: %s -> %s/%s", localPath, remoteFolder, filename)
logger.Add("INFO", "SMB", "Добавлен в очередь повторных попыток | Файл: "+filename+" | Папка: "+remoteFolder)
}
func (s *SMBClient) ProcessRetryQueue() {
@@ -361,6 +382,7 @@ func (s *SMBClient) ProcessRetryQueue() {
if _, err := os.Stat(item.LocalPath); os.IsNotExist(err) {
log.Printf("File %s no longer exists, removing from retry queue", item.LocalPath)
logger.Add("INFO", "SMB", "Файл удален из очереди (не существует) | Файл: "+item.Filename)
retryMu.Lock()
delete(retryItems, item.LocalPath)
retryMu.Unlock()
@@ -369,19 +391,21 @@ func (s *SMBClient) ProcessRetryQueue() {
item.Attempts++
log.Printf("Retry attempt %d for file %s", item.Attempts, item.LocalPath)
logger.Add("INFO", "SMB", "Повторная попытка отправки (попытка "+string(rune(item.Attempts))+") | Файл: "+item.Filename)
err := s.UploadFile(item.LocalPath, item.RemoteFolder, item.Filename)
if err != nil {
log.Printf("Retry failed for %s: %v", item.LocalPath, err)
if item.Attempts >= 5 {
log.Printf("Max retry attempts reached for %s, giving up", item.LocalPath)
logger.Add("ERROR", "SMB", "Превышено максимальное количество попыток | Файл: "+item.Filename)
retryMu.Lock()
delete(retryItems, item.LocalPath)
retryMu.Unlock()
}
} else {
os.Remove(item.LocalPath)
log.Printf("File %s uploaded successfully on retry and deleted", item.LocalPath)
log.Printf("File %s uploaded successfully on retry", item.LocalPath)
logger.AddSMB(item.Filename, true, "")
retryMu.Lock()
delete(retryItems, item.LocalPath)
retryMu.Unlock()
@@ -391,7 +415,6 @@ func (s *SMBClient) ProcessRetryQueue() {
}
}
// updateRecordingStatus обновляет статус SMB в БД
func (s *SMBClient) updateRecordingStatus(filePath string, uploaded bool, errorMsg string) {
db := database.GetDB()
if db == nil {
@@ -421,6 +444,13 @@ func (s *SMBClient) updateRecordingStatus(filePath string, uploaded bool, errorM
} else {
log.Printf("Updated SMB status for recording %s: uploaded=%v", filename, uploaded)
}
// УДАЛИТЕ ЭТИ СТРОКИ - они дублируют лог из UploadFile
// if uploaded {
// logger.AddSMB(filename, true, "")
// } else {
// logger.AddSMB(filename, false, errorMsg)
// }
break
}
}
+48 -22
View File
@@ -19,8 +19,9 @@
<div class="tabs">
<button class="tab-btn active" data-tab="channel">📊 Состояние канала</button>
<button class="tab-btn" data-tab="archive">📀 Архив</button>
<button class="tab-btn" data-tab="settings">⚙️ Настройки устройства</button>
<button class="tab-btn" data-tab="storage">💾 Хранилище и FTP</button>
<button class="tab-btn" data-tab="settings">⚙️ Настройки канала</button>
<button class="tab-btn" data-tab="storage">💾 Настройки устройства</button>
<button class="tab-btn" data-tab="logs">📋 Логи</button>
</div>
<!-- Вкладка: Состояние канала -->
@@ -135,7 +136,7 @@
</div>
</div>
<!-- Вкладка: Настройки устройства -->
<!-- Вкладка: Настройки канала -->
<div id="tab-settings" class="tab-content">
<div class="settings-card">
<h3>🎚️ Управление каналами записи</h3>
@@ -228,7 +229,7 @@
</div>
</div>
<!-- Планировщик записи (перемещен выше кнопки сохранения) -->
<!-- Планировщик записи -->
<div class="settings-group" style="margin-top: 20px; border-top: 1px solid #e0e0e0; padding-top: 20px;">
<h4 style="margin-bottom: 15px; color: #667eea;">⏰ Планировщик записи</h4>
@@ -279,26 +280,9 @@
<button id="deleteChannelBtn" class="btn btn-danger" style="display: none;">🗑️ Удалить канал</button>
</div>
</div>
<!-- Осциллограф -->
<div class="info-card">
<h3>📊 Визуализация звука</h3>
<canvas id="liveOscilloscope" style="width: 100%; height: 200px; background: #1a1a2e; border-radius: 10px;"></canvas>
<div id="liveLevel" style="margin-top: 10px; font-size: 12px; text-align: center;">Уровень сигнала: -∞ dB</div>
</div>
<div class="settings-card">
<h3>🔐 Смена пароля</h3>
<div class="settings-group">
<input type="password" id="oldPassword" placeholder="Старый пароль" style="width: 100%; margin-bottom: 10px;">
<input type="password" id="newPassword" placeholder="Новый пароль" style="width: 100%; margin-bottom: 10px;">
<input type="password" id="confirmPassword" placeholder="Подтвердите новый пароль" style="width: 100%; margin-bottom: 10px;">
<button id="changePasswordBtn" class="btn btn-secondary">Сменить пароль</button>
</div>
</div>
</div>
<!-- Вкладка: Хранилище и FTP -->
<!-- Вкладка: Настройки устройства -->
<div id="tab-storage" class="tab-content">
<div class="settings-card">
<h3>💾 Настройки хранилища</h3>
@@ -461,6 +445,48 @@
При ошибке отправки файл будет добавлен в очередь и отправлен позже.
</div>
</div>
<!-- Смена пароля (перенесена сюда) -->
<div class="settings-card">
<h3>🔐 Смена пароля</h3>
<div class="settings-group">
<input type="password" id="oldPassword" placeholder="Старый пароль" style="width: 100%; margin-bottom: 10px;">
<input type="password" id="newPassword" placeholder="Новый пароль" style="width: 100%; margin-bottom: 10px;">
<input type="password" id="confirmPassword" placeholder="Подтвердите новый пароль" style="width: 100%; margin-bottom: 10px;">
<button id="changePasswordBtn" class="btn btn-secondary">Сменить пароль</button>
</div>
</div>
</div>
<!-- Вкладка: Логи -->
<div id="tab-logs" class="tab-content">
<div class="settings-card">
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3>📋 Системные логи</h3>
<div>
<button id="clearLogsBtn" class="btn btn-danger">🗑️ Очистить логи</button>
<button id="refreshLogsBtn" class="btn btn-config">🔄 Обновить</button>
</div>
</div>
<div class="logs-filters" style="margin-bottom: 15px; display: flex; gap: 10px;">
<select id="logFilterCategory" class="filter-select">
<option value="all">Все категории</option>
<option value="RECORDING">Записи</option>
<option value="SMB">SMB</option>
<option value="SETTINGS">Настройки</option>
<option value="SYSTEM">Система</option>
</select>
<select id="logFilterLevel" class="filter-select">
<option value="all">Все уровни</option>
<option value="INFO">Инфо</option>
<option value="SETTINGS">Настройки</option>
<option value="ERROR">Ошибки</option>
</select>
</div>
<div id="logsList" class="logs-list" style="max-height: 500px; overflow-y: auto; background: #1a1a2e; border-radius: 10px; padding: 10px; font-family: monospace; font-size: 12px;">
<div class="loading">Загрузка логов...</div>
</div>
</div>
</div>
</div>
+53 -48
View File
@@ -2,14 +2,14 @@
import { checkAuth, logout, changePassword } from './modules/api.js';
import { disconnectWebSocket, initWebSocket } from './modules/websocket.js';
import { initTabs, showNotification } from './modules/ui.js';
import { loadDevices } from './modules/devices.js';
import { loadDevices, setSelectedDevice } from './modules/devices.js';
import { loadRecordings, downloadRecording, deleteSelectedRecordings } from './modules/recordings.js';
import { togglePlay } from './modules/player.js';
import { initOscilloscope, drawWaveform } from './modules/oscilloscope.js';
import { loadLogs, initLogsFilters, clearLogs } from './modules/logs.js';
import {
loadChannels, selectChannel, saveChannelConfig, addChannel, deleteChannel,
startRecording, stopRecording, loadStatus, getCurrentChannelId, setCurrentChannelId,
getChannels
getChannels, loadChannelConfig
} from './modules/channels.js';
import {
loadStorageSettings, saveStorageSettings,
@@ -39,18 +39,13 @@ window.deleteSelectedRecordings = deleteSelectedRecordings;
window.showNotification = showNotification;
window.getChannels = getChannels;
window.setCurrentChannelId = setCurrentChannelId;
window.clearLogs = clearLogs;
// Инициализация WebSocket с callback для осциллографа
initWebSocket((samples) => {
drawWaveform(samples);
});
// Инициализация онлайн плеера
initLivePlayer();
// Инициализация WebSocket
initWebSocket(null);
// Функция для привязки обработчиков к динамическим элементам
function bindEventHandlers() {
// Старт записи
const startBtn = document.getElementById('startBtn');
if (startBtn && !startBtn._hasHandler) {
startBtn.onclick = startRecording;
@@ -58,7 +53,6 @@ function bindEventHandlers() {
console.log('Start button handler attached');
}
// Стоп записи
const stopBtn = document.getElementById('stopBtn');
if (stopBtn && !stopBtn._hasHandler) {
stopBtn.onclick = stopRecording;
@@ -66,7 +60,6 @@ function bindEventHandlers() {
console.log('Stop button handler attached');
}
// Онлайн прослушивание
const playLiveBtn = document.getElementById('playLiveBtn');
if (playLiveBtn && !playLiveBtn._hasHandler) {
playLiveBtn.onclick = () => startLivePlayback(getCurrentChannelId());
@@ -85,13 +78,13 @@ function bindEventHandlers() {
if (liveVolume && !liveVolume._hasHandler) {
liveVolume.oninput = (e) => {
const value = e.target.value;
document.getElementById('volumeValue').textContent = value + '%';
const volumeValue = document.getElementById('volumeValue');
if (volumeValue) volumeValue.textContent = value + '%';
setLiveVolume(value);
};
liveVolume._hasHandler = true;
}
// Сохранение конфигурации канала
const saveConfigBtn = document.getElementById('saveChannelConfigBtn');
if (saveConfigBtn && !saveConfigBtn._hasHandler) {
saveConfigBtn.onclick = saveChannelConfig;
@@ -99,7 +92,6 @@ function bindEventHandlers() {
console.log('Save config button handler attached');
}
// Добавление канала
const addChannelBtn = document.getElementById('addChannelBtn');
if (addChannelBtn && !addChannelBtn._hasHandler) {
addChannelBtn.onclick = addChannel;
@@ -107,7 +99,6 @@ function bindEventHandlers() {
console.log('Add channel button handler attached');
}
// Удаление канала
const deleteChannelBtn = document.getElementById('deleteChannelBtn');
if (deleteChannelBtn && !deleteChannelBtn._hasHandler) {
deleteChannelBtn.onclick = deleteChannel;
@@ -115,7 +106,6 @@ function bindEventHandlers() {
console.log('Delete channel button handler attached');
}
// Обновление архива
const refreshArchiveBtn = document.getElementById('refreshArchiveBtn');
if (refreshArchiveBtn && !refreshArchiveBtn._hasHandler) {
refreshArchiveBtn.onclick = loadRecordings;
@@ -123,15 +113,13 @@ function bindEventHandlers() {
console.log('Refresh archive button handler attached');
}
// Сканирование устройств
const scanDevicesBtn = document.getElementById('scanDevicesBtn');
if (scanDevicesBtn && !scanDevicesBtn._hasHandler) {
scanDevicesBtn.onclick = loadDevices;
scanDevicesBtn.onclick = () => loadDevices(true, true);
scanDevicesBtn._hasHandler = true;
console.log('Scan devices button handler attached');
}
// Смена пароля
const changePasswordBtn = document.getElementById('changePasswordBtn');
if (changePasswordBtn && !changePasswordBtn._hasHandler) {
changePasswordBtn.onclick = async () => {
@@ -169,7 +157,6 @@ function bindEventHandlers() {
console.log('Change password button handler attached');
}
// Выход
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn && !logoutBtn._hasHandler) {
logoutBtn.onclick = async () => {
@@ -181,7 +168,6 @@ function bindEventHandlers() {
console.log('Logout button handler attached');
}
// Выбор канала в настройках
const channelSelect = document.getElementById('channelSelect');
if (channelSelect && !channelSelect._hasHandler) {
channelSelect.onchange = (e) => {
@@ -192,7 +178,6 @@ function bindEventHandlers() {
channelSelect._hasHandler = true;
}
// Выбор активного канала
const activeChannelSelect = document.getElementById('activeChannelSelect');
if (activeChannelSelect && !activeChannelSelect._hasHandler) {
activeChannelSelect.onchange = (e) => {
@@ -204,14 +189,12 @@ function bindEventHandlers() {
activeChannelSelect._hasHandler = true;
}
// Фильтр каналов в архиве
const filterChannelSelect = document.getElementById('filterChannelSelect');
if (filterChannelSelect && !filterChannelSelect._hasHandler) {
filterChannelSelect.onchange = () => loadRecordings();
filterChannelSelect._hasHandler = true;
}
// Изменение формата
const formatSelect = document.getElementById('format');
if (formatSelect && !formatSelect._hasHandler) {
formatSelect.onchange = () => {
@@ -223,7 +206,6 @@ function bindEventHandlers() {
formatSelect._hasHandler = true;
}
// Пресеты длительности
const presetBtns = document.querySelectorAll('.preset-btn');
presetBtns.forEach(btn => {
if (!btn._hasHandler) {
@@ -236,7 +218,6 @@ function bindEventHandlers() {
}
});
// Сохранение настроек хранилища
const saveStorageBtn = document.getElementById('saveStorageBtn');
if (saveStorageBtn && !saveStorageBtn._hasHandler) {
saveStorageBtn.onclick = saveStorageSettings;
@@ -244,7 +225,6 @@ function bindEventHandlers() {
console.log('Save storage button handler attached');
}
// Сохранение настроек FTP
const saveFtpBtn = document.getElementById('saveFtpBtn');
if (saveFtpBtn && !saveFtpBtn._hasHandler) {
saveFtpBtn.onclick = saveFTPSettings;
@@ -252,7 +232,6 @@ function bindEventHandlers() {
console.log('Save FTP button handler attached');
}
// Запуск FTP
const startFtpBtn = document.getElementById('startFtpBtn');
if (startFtpBtn && !startFtpBtn._hasHandler) {
startFtpBtn.onclick = startFTPServer;
@@ -260,7 +239,6 @@ function bindEventHandlers() {
console.log('Start FTP button handler attached');
}
// Остановка FTP
const stopFtpBtn = document.getElementById('stopFtpBtn');
if (stopFtpBtn && !stopFtpBtn._hasHandler) {
stopFtpBtn.onclick = stopFTPServer;
@@ -268,7 +246,6 @@ function bindEventHandlers() {
console.log('Stop FTP button handler attached');
}
// Показать/скрыть пароль FTP
const showPasswordBtn = document.getElementById('showFtpPasswordBtn');
if (showPasswordBtn && !showPasswordBtn._hasHandler) {
showPasswordBtn.onclick = togglePasswordVisibility;
@@ -276,7 +253,6 @@ function bindEventHandlers() {
console.log('Show password button handler attached');
}
// Сохранение настроек SMB
const saveSmbBtn = document.getElementById('saveSmbBtn');
if (saveSmbBtn && !saveSmbBtn._hasHandler) {
saveSmbBtn.onclick = saveSMBSettings;
@@ -284,7 +260,6 @@ function bindEventHandlers() {
console.log('Save SMB button handler attached');
}
// Тест SMB подключения
const testSmbBtn = document.getElementById('testSmbBtn');
if (testSmbBtn && !testSmbBtn._hasHandler) {
testSmbBtn.onclick = testSMBConnection;
@@ -292,7 +267,6 @@ function bindEventHandlers() {
console.log('Test SMB button handler attached');
}
// Показать/скрыть пароль SMB
const showSmbPasswordBtn = document.getElementById('showSmbPasswordBtn');
if (showSmbPasswordBtn && !showSmbPasswordBtn._hasHandler) {
showSmbPasswordBtn.onclick = toggleSmbPasswordVisibility;
@@ -300,7 +274,6 @@ function bindEventHandlers() {
console.log('Show SMB password button handler attached');
}
// Удаление выбранных записей
const deleteSelectedBtn = document.getElementById('deleteSelectedBtn');
if (deleteSelectedBtn && !deleteSelectedBtn._hasHandler) {
deleteSelectedBtn.onclick = deleteSelectedRecordings;
@@ -308,7 +281,6 @@ function bindEventHandlers() {
console.log('Delete selected button handler attached');
}
// Выбрать все записи
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
if (selectAllCheckbox && !selectAllCheckbox._hasHandler) {
selectAllCheckbox.onchange = () => {
@@ -320,9 +292,22 @@ function bindEventHandlers() {
};
selectAllCheckbox._hasHandler = true;
}
const clearLogsBtn = document.getElementById('clearLogsBtn');
if (clearLogsBtn && !clearLogsBtn._hasHandler) {
clearLogsBtn.onclick = clearLogs;
clearLogsBtn._hasHandler = true;
console.log('Clear logs button handler attached');
}
const refreshLogsBtn = document.getElementById('refreshLogsBtn');
if (refreshLogsBtn && !refreshLogsBtn._hasHandler) {
refreshLogsBtn.onclick = () => loadLogs();
refreshLogsBtn._hasHandler = true;
console.log('Refresh logs button handler attached');
}
}
// Функция для наблюдения за изменениями DOM
function observeDOM() {
const observer = new MutationObserver(() => {
bindEventHandlers();
@@ -339,35 +324,44 @@ function observeDOM() {
document.addEventListener('DOMContentLoaded', async () => {
console.log('DOM загружен, инициализация приложения...');
initOscilloscope();
initLivePlayer();
const isAuth = await checkAuth();
if (!isAuth) return;
initTabs();
const domObserver = observeDOM();
observeDOM();
setTimeout(() => {
bindEventHandlers();
}, 100);
await loadChannels(false);
await loadDevices();
await loadDevices(false, true);
const currentChannelId = getCurrentChannelId();
if (currentChannelId) {
const config = await loadChannelConfig(currentChannelId);
if (config && config.device) {
setSelectedDevice(config.device);
console.log('Restored device from config:', config.device);
}
}
await loadRecordings();
await loadStorageSettings();
await loadFTPSettings();
await loadSMBSettings();
const currentChannel = getCurrentChannelId();
if (currentChannel) {
if (currentChannelId) {
await loadStatus();
}
if (updateInterval) clearInterval(updateInterval);
updateInterval = setInterval(async () => {
const currentChannelId = getCurrentChannelId();
if (currentChannelId) {
const currentId = getCurrentChannelId();
if (currentId) {
await loadStatus();
}
await loadChannels(true);
@@ -379,12 +373,19 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadRecordings();
}
if (tabId === 'settings') {
await loadDevices();
await loadDevices(false, true);
await loadChannels(true);
const currentId = getCurrentChannelId();
if (currentId) {
const config = await loadChannelConfig(currentId);
if (config && config.device) {
setSelectedDevice(config.device);
}
}
}
if (tabId === 'channel') {
const currentChannelId = getCurrentChannelId();
if (currentChannelId) {
const currentId = getCurrentChannelId();
if (currentId) {
await loadStatus();
}
}
@@ -393,6 +394,10 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadFTPSettings();
await loadSMBSettings();
}
if (tabId === 'logs') {
await loadLogs();
initLogsFilters();
}
setTimeout(() => bindEventHandlers(), 100);
});
+41 -35
View File
@@ -9,7 +9,7 @@ let channels = [];
let configCache = {};
let lastConfigLoad = {};
let currentChannel = null;
let isUpdatingSelects = false; // Флаг для предотвращения рекурсивных обновлений
let isUpdatingSelects = false;
export function getChannels() {
return channels;
@@ -24,13 +24,11 @@ export function getCurrentChannelId() {
return currentChannel;
}
// Функция для получения следующего доступного номера канала
function getNextAvailableChannelNumber() {
if (!channels || channels.length === 0) {
return 1;
}
// Извлекаем все номера из существующих каналов
const numbers = [];
for (const ch of channels) {
const match = ch.id.match(/channel(\d+)/);
@@ -39,7 +37,6 @@ function getNextAvailableChannelNumber() {
}
}
// Ищем наименьший свободный номер, начиная с 1
for (let i = 1; i <= numbers.length + 1; i++) {
if (!numbers.includes(i)) {
return i;
@@ -48,16 +45,13 @@ function getNextAvailableChannelNumber() {
return numbers.length + 1;
}
// Функция для обновления выпадающих списков без пересоздания
function updateSelectsWithoutRebuild() {
const channelSelect = document.getElementById('channelSelect');
const activeChannelSelect = document.getElementById('activeChannelSelect');
const filterChannelSelect = document.getElementById('filterChannelSelect');
// Обновляем channelSelect
if (channelSelect) {
const currentValue = channelSelect.value;
// Очищаем только опции, сохраняя выбранное значение если возможно
const options = channelSelect.querySelectorAll('option');
const existingIds = new Set();
@@ -67,7 +61,6 @@ function updateSelectsWithoutRebuild() {
}
});
// Добавляем новые каналы
for (const ch of channels) {
if (!existingIds.has(ch.id)) {
const option = document.createElement('option');
@@ -75,7 +68,6 @@ function updateSelectsWithoutRebuild() {
option.textContent = `${ch.name || ch.id}${ch.is_recording ? ' 🔴' : ''}`;
channelSelect.appendChild(option);
} else {
// Обновляем существующие опции
const existingOpt = Array.from(options).find(opt => opt.value === ch.id);
if (existingOpt) {
existingOpt.textContent = `${ch.name || ch.id}${ch.is_recording ? ' 🔴' : ''}`;
@@ -83,14 +75,12 @@ function updateSelectsWithoutRebuild() {
}
}
// Удаляем удаленные каналы
for (const opt of options) {
if (opt.value && opt.value !== '' && !channels.find(ch => ch.id === opt.value)) {
opt.remove();
}
}
// Восстанавливаем выбранное значение если оно еще существует
if (currentValue && channels.find(ch => ch.id === currentValue)) {
channelSelect.value = currentValue;
} else if (channels.length > 0 && !currentValue && currentChannel) {
@@ -103,7 +93,6 @@ function updateSelectsWithoutRebuild() {
}
}
// Обновляем activeChannelSelect
if (activeChannelSelect) {
const currentValue = activeChannelSelect.value;
const options = activeChannelSelect.querySelectorAll('option');
@@ -115,7 +104,6 @@ function updateSelectsWithoutRebuild() {
}
});
// Добавляем новые каналы
for (const ch of channels) {
if (!existingIds.has(ch.id)) {
const option = document.createElement('option');
@@ -124,7 +112,6 @@ function updateSelectsWithoutRebuild() {
option.textContent = `${status} ${ch.name || ch.id} (${ch.device || 'нет устройства'})`;
activeChannelSelect.appendChild(option);
} else {
// Обновляем существующие опции
const existingOpt = Array.from(options).find(opt => opt.value === ch.id);
if (existingOpt) {
const status = ch.is_recording ? '🔴' : '⚫';
@@ -133,7 +120,6 @@ function updateSelectsWithoutRebuild() {
}
}
// Удаляем удаленные каналы
for (const opt of options) {
if (opt.value && opt.value !== '' && !channels.find(ch => ch.id === opt.value)) {
opt.remove();
@@ -147,12 +133,10 @@ function updateSelectsWithoutRebuild() {
}
}
// Обновляем filterChannelSelect
if (filterChannelSelect) {
const currentValue = filterChannelSelect.value;
const options = filterChannelSelect.querySelectorAll('option');
// Сохраняем опцию "Все каналы"
let allOption = Array.from(options).find(opt => opt.value === 'all');
if (!allOption) {
allOption = document.createElement('option');
@@ -161,7 +145,6 @@ function updateSelectsWithoutRebuild() {
filterChannelSelect.insertBefore(allOption, filterChannelSelect.firstChild);
}
// Обновляем опции каналов
for (const ch of channels) {
let existingOpt = Array.from(options).find(opt => opt.value === ch.id);
if (!existingOpt) {
@@ -172,7 +155,6 @@ function updateSelectsWithoutRebuild() {
existingOpt.textContent = ch.name || ch.id;
}
// Удаляем удаленные каналы
for (const opt of options) {
if (opt.value && opt.value !== 'all' && !channels.find(ch => ch.id === opt.value)) {
opt.remove();
@@ -193,7 +175,6 @@ export async function loadChannels(skipConfigLoad = false) {
if (!response || !response.ok) throw new Error(`HTTP error! status: ${response?.status}`);
const newChannels = await response.json();
// Сортируем каналы по номеру для стабильного порядка
const sortedChannels = [...newChannels].sort((a, b) => {
const numA = parseInt(a.id.match(/channel(\d+)/)?.[1] || 0);
const numB = parseInt(b.id.match(/channel(\d+)/)?.[1] || 0);
@@ -202,7 +183,6 @@ export async function loadChannels(skipConfigLoad = false) {
channels = sortedChannels;
// Обновляем выпадающие списки без пересоздания
isUpdatingSelects = true;
updateSelectsWithoutRebuild();
isUpdatingSelects = false;
@@ -261,7 +241,6 @@ export async function selectChannel(channelId, skipConfigLoad = false) {
currentChannel = channelId;
setCurrentChannel(channelId);
// Обновляем выбранные значения в выпадающих списках без перезагрузки
const channelSelect = document.getElementById('channelSelect');
const activeChannelSelect = document.getElementById('activeChannelSelect');
@@ -275,11 +254,13 @@ export async function selectChannel(channelId, skipConfigLoad = false) {
updateChannelsList();
if (!skipConfigLoad) {
if (!configCache[channelId] || !lastConfigLoad[channelId] ||
(Date.now() - lastConfigLoad[channelId] > 5000)) {
await loadChannelConfig(channelId);
} else {
applyConfigToForm(configCache[channelId]);
const config = await loadChannelConfig(channelId);
await loadDevices(false, true);
if (config && config.device) {
const { setSelectedDevice } = await import('./devices.js');
setSelectedDevice(config.device);
}
await loadStatus();
@@ -319,7 +300,7 @@ async function loadSchedule(channelId) {
}
}
async function loadChannelConfig(channelId, force = false) {
export async function loadChannelConfig(channelId, force = false) {
if (!force && configCache[channelId] && lastConfigLoad[channelId] &&
(Date.now() - lastConfigLoad[channelId] < 5000)) {
applyConfigToForm(configCache[channelId]);
@@ -330,9 +311,11 @@ async function loadChannelConfig(channelId, force = false) {
const response = await fetchWithAuth(`/api/config?channel=${channelId}`);
if (!response || !response.ok) throw new Error(`HTTP error! status: ${response?.status}`);
const config = await response.json();
console.log('Loaded config for channel', channelId, config);
configCache[channelId] = config;
lastConfigLoad[channelId] = Date.now();
applyConfigToForm(config);
await applyConfigToFormWithDevices(config, channelId);
return config;
} catch (error) {
console.error('Ошибка загрузки конфигурации:', error);
@@ -340,16 +323,42 @@ async function loadChannelConfig(channelId, force = false) {
}
}
async function applyConfigToFormWithDevices(config, channelId) {
let attempts = 0;
const deviceSelect = document.getElementById('deviceSelect');
while (deviceSelect && deviceSelect.options.length <= 1 && attempts < 20) {
await new Promise(resolve => setTimeout(resolve, 500));
attempts++;
}
applyConfigToForm(config);
if (config && config.device) {
const { setSelectedDevice } = await import('./devices.js');
setSelectedDevice(config.device);
}
}
function applyConfigToForm(config) {
console.log('Applying config to form:', config);
const channelNameInput = document.getElementById('channelName');
if (channelNameInput) channelNameInput.value = config.channel_name || '';
const deviceSelect = document.getElementById('deviceSelect');
if (deviceSelect && config.device && deviceSelect.value !== config.device) {
// Проверяем существует ли такая опция
const optionExists = Array.from(deviceSelect.options).some(opt => opt.value === config.device);
if (deviceSelect && config.device) {
let optionExists = false;
for (let i = 0; i < deviceSelect.options.length; i++) {
if (deviceSelect.options[i].value === config.device) {
optionExists = true;
break;
}
}
if (optionExists) {
deviceSelect.value = config.device;
} else {
console.log('Device not found in list yet, will try later:', config.device);
}
}
@@ -374,7 +383,6 @@ function applyConfigToForm(config) {
smbFolderInput.value = config.smb_folder;
}
// Настройки планировщика
const scheduleEnabled = document.getElementById('scheduleEnabled');
const scheduleSettings = document.getElementById('scheduleSettings');
const scheduleInfo = document.getElementById('scheduleInfo');
@@ -392,7 +400,6 @@ function applyConfigToForm(config) {
scheduleSettings.style.display = scheduleEnabled.checked ? 'block' : 'none';
}
// Убираем дублирование обработчика
if (!scheduleEnabled._handlerSet) {
scheduleEnabled.onchange = function() {
if (scheduleSettings) {
@@ -425,7 +432,6 @@ function applyConfigToForm(config) {
}
}
// Добавляем обработчики только один раз
if (scheduleStartTime && !scheduleStartTime._handlerSet) {
scheduleStartTime.addEventListener('change', updateScheduleInfo);
scheduleStartTime._handlerSet = true;
+51 -28
View File
@@ -1,16 +1,24 @@
// frontend/js/modules/devices.js
import { fetchWithAuth } from './api.js';
import { showNotification, escapeHtml } from './ui.js';
let cachedDevices = null;
let lastDeviceLoad = 0;
const DEVICE_CACHE_DURATION = 30000; // 30 секунд кэширования
let pendingDeviceValue = null; // Сохраняем выбранное устройство
export async function loadDevices(force = false) {
export async function loadDevices(force = false, preserveSelectedValue = true) {
const now = Date.now();
// Используем кэш если он свежий и не принудительная загрузка
// Сохраняем текущее выбранное значение перед загрузкой
let currentSelectedValue = null;
const deviceSelect = document.getElementById('deviceSelect');
if (preserveSelectedValue && deviceSelect) {
currentSelectedValue = deviceSelect.value;
}
if (!force && cachedDevices && (now - lastDeviceLoad) < DEVICE_CACHE_DURATION) {
updateDeviceSelect(cachedDevices);
updateDeviceSelect(cachedDevices, currentSelectedValue);
return cachedDevices;
}
@@ -22,30 +30,32 @@ export async function loadDevices(force = false) {
cachedDevices = devices;
lastDeviceLoad = now;
updateDeviceSelect(devices);
updateDeviceSelect(devices, currentSelectedValue);
return devices;
} catch (error) {
console.error('Ошибка загрузки устройств:', error);
// Если есть кэш, используем его даже если устарел
if (cachedDevices) {
updateDeviceSelect(cachedDevices);
updateDeviceSelect(cachedDevices, currentSelectedValue);
}
return cachedDevices || [];
}
}
function updateDeviceSelect(devices) {
function updateDeviceSelect(devices, preserveValue = null) {
const deviceSelect = document.getElementById('deviceSelect');
if (!deviceSelect) return;
// Сохраняем текущее выбранное значение
const currentValue = deviceSelect.value;
// Сохраняем значение, которое нужно восстановить
let valueToRestore = preserveValue;
if (!valueToRestore && deviceSelect.value) {
valueToRestore = deviceSelect.value;
}
// Сортируем устройства для стабильного порядка
// Сортируем устройства
const workingDevices = devices.filter(dev => dev.working).sort((a, b) => a.name.localeCompare(b.name));
const nonWorkingDevices = devices.filter(dev => !dev.working).sort((a, b) => a.name.localeCompare(b.name));
// Создаем новый select, сохраняя структуру
// Создаем новый select
const newSelect = document.createElement('select');
newSelect.id = 'deviceSelect';
newSelect.className = deviceSelect.className;
@@ -62,7 +72,7 @@ function updateDeviceSelect(devices) {
const option = document.createElement('option');
option.value = dev.name;
option.textContent = `${dev.name} - ${dev.description}`;
if (currentValue === dev.name) {
if (valueToRestore === dev.name) {
option.selected = true;
}
optgroupWorking.appendChild(option);
@@ -77,7 +87,7 @@ function updateDeviceSelect(devices) {
const option = document.createElement('option');
option.value = dev.name;
option.textContent = `${dev.name} - ${dev.description} (${dev.test_message || 'не работает'})`;
if (currentValue === dev.name) {
if (valueToRestore === dev.name) {
option.selected = true;
}
optgroupNonWorking.appendChild(option);
@@ -85,39 +95,52 @@ function updateDeviceSelect(devices) {
newSelect.appendChild(optgroupNonWorking);
}
// Заменяем select, сохраняя обработчики
const parent = deviceSelect.parentNode;
const nextSibling = deviceSelect.nextSibling;
// Копируем обработчики событий
const oldOnChange = deviceSelect.onchange;
if (oldOnChange) {
newSelect.onchange = oldOnChange;
}
// Заменяем select
const parent = deviceSelect.parentNode;
const nextSibling = deviceSelect.nextSibling;
parent.removeChild(deviceSelect);
if (nextSibling) {
parent.insertBefore(newSelect, nextSibling);
} else {
parent.appendChild(newSelect);
}
// Если значение не сохранилось и есть рабочие устройства - выбираем первое
if (!currentValue && workingDevices.length > 0) {
newSelect.value = workingDevices[0].name;
} else if (currentValue) {
// Проверяем, существует ли выбранное устройство и работает ли оно
const selectedDevice = devices.find(d => d.name === currentValue);
if (selectedDevice && !selectedDevice.working && workingDevices.length > 0) {
newSelect.value = workingDevices[0].name;
showNotification(`Устройство ${currentValue} не работает. Выбрано ${workingDevices[0].name}`, 'warning');
}
// Функция для установки значения устройства (вызывается из channels.js)
export function setSelectedDevice(deviceName) {
const deviceSelect = document.getElementById('deviceSelect');
if (deviceSelect && deviceName) {
// Проверяем существует ли такая опция
let optionExists = false;
for (let i = 0; i < deviceSelect.options.length; i++) {
if (deviceSelect.options[i].value === deviceName) {
optionExists = true;
break;
}
}
if (optionExists) {
deviceSelect.value = deviceName;
} else {
// Добавляем опцию если её нет
const option = document.createElement('option');
option.value = deviceName;
option.textContent = deviceName;
deviceSelect.appendChild(option);
deviceSelect.value = deviceName;
}
console.log('Device set to:', deviceName);
}
}
// Функция для принудительного обновления устройств
export async function refreshDevices() {
await loadDevices(true);
await loadDevices(true, true);
}
// Функция для получения текущего выбранного устройства
+93
View File
@@ -0,0 +1,93 @@
// frontend/js/modules/logs.js
import { fetchWithAuth } from './api.js';
import { showNotification } from './ui.js';
export async function loadLogs() {
try {
const response = await fetchWithAuth('/api/logs');
if (response && response.ok) {
const logs = await response.json();
displayLogs(logs);
}
} catch (error) {
console.error('Ошибка загрузки логов:', error);
const container = document.getElementById('logsList');
if (container) {
container.innerHTML = '<div class="loading">Ошибка загрузки логов</div>';
}
}
}
function displayLogs(logs) {
const container = document.getElementById('logsList');
const categoryFilter = document.getElementById('logFilterCategory')?.value || 'all';
const levelFilter = document.getElementById('logFilterLevel')?.value || 'all';
if (!container) return;
if (!logs || logs.length === 0) {
container.innerHTML = '<div class="empty-message">Нет записей в логах</div>';
return;
}
let filteredLogs = logs;
if (categoryFilter !== 'all') {
filteredLogs = filteredLogs.filter(log => log.category === categoryFilter);
}
if (levelFilter !== 'all') {
filteredLogs = filteredLogs.filter(log => log.level === levelFilter);
}
container.innerHTML = filteredLogs.map(log => `
<div class="log-entry">
<span class="log-time">${new Date(log.timestamp).toLocaleString('ru-RU')}</span>
<span class="log-level ${log.level}">${log.level}</span>
<span class="log-category">${log.category}</span>
<span class="log-message">${escapeHtml(log.message)}</span>
${log.details ? `<span class="log-details">${escapeHtml(log.details)}</span>` : ''}
</div>
`).join('');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
export async function clearLogs() {
try {
const response = await fetchWithAuth('/api/logs/clear', { method: 'POST' });
if (response && response.ok) {
showNotification('Логи очищены', 'success');
await loadLogs();
} else {
showNotification('Ошибка очистки логов', 'error');
}
} catch (error) {
console.error('Ошибка:', error);
showNotification('Ошибка очистки логов', 'error');
}
}
export function initLogsFilters() {
const categoryFilter = document.getElementById('logFilterCategory');
const levelFilter = document.getElementById('logFilterLevel');
if (categoryFilter) {
categoryFilter.onchange = () => loadLogs();
}
if (levelFilter) {
levelFilter.onchange = () => loadLogs();
}
const clearBtn = document.getElementById('clearLogsBtn');
if (clearBtn) {
clearBtn.onclick = clearLogs;
}
const refreshBtn = document.getElementById('refreshLogsBtn');
if (refreshBtn) {
refreshBtn.onclick = loadLogs;
}
}
-102
View File
@@ -1,102 +0,0 @@
// Осциллограмма модуль
let liveCanvas = null;
let liveCtx = null;
export function initOscilloscope() {
liveCanvas = document.getElementById('liveOscilloscope');
if (liveCanvas) {
liveCtx = liveCanvas.getContext('2d');
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
if (liveCtx) {
liveCtx.fillStyle = '#1a1a2e';
liveCtx.fillRect(0, 0, liveCanvas.width, liveCanvas.height);
liveCtx.beginPath();
liveCtx.strokeStyle = '#888';
liveCtx.lineWidth = 1;
liveCtx.moveTo(0, liveCanvas.height / 2);
liveCtx.lineTo(liveCanvas.width, liveCanvas.height / 2);
liveCtx.stroke();
}
}
}
function resizeCanvas() {
if (liveCanvas && liveCanvas.parentElement) {
liveCanvas.width = liveCanvas.parentElement.clientWidth - 40;
liveCanvas.height = 200;
if (liveCtx) {
liveCtx.fillStyle = '#1a1a2e';
liveCtx.fillRect(0, 0, liveCanvas.width, liveCanvas.height);
liveCtx.beginPath();
liveCtx.strokeStyle = '#888';
liveCtx.lineWidth = 1;
liveCtx.moveTo(0, liveCanvas.height / 2);
liveCtx.lineTo(liveCanvas.width, liveCanvas.height / 2);
liveCtx.stroke();
}
}
}
export function drawWaveform(samples, color = '#667eea') {
if (!liveCtx || !liveCanvas || !samples || samples.length === 0) {
return;
}
const width = liveCanvas.width;
const height = liveCanvas.height;
const centerY = height / 2;
liveCtx.fillStyle = '#1a1a2e';
liveCtx.fillRect(0, 0, width, height);
liveCtx.beginPath();
liveCtx.strokeStyle = '#888';
liveCtx.lineWidth = 1;
liveCtx.moveTo(0, centerY);
liveCtx.lineTo(width, centerY);
liveCtx.stroke();
liveCtx.beginPath();
liveCtx.strokeStyle = color;
liveCtx.lineWidth = 2;
const step = width / samples.length;
let x = 0;
for (let i = 0; i < samples.length; i++) {
const y = centerY + (samples[i] * centerY);
if (i === 0) {
liveCtx.moveTo(x, y);
} else {
liveCtx.lineTo(x, y);
}
x += step;
}
liveCtx.stroke();
// Обновляем уровень сигнала
let sum = 0;
for (let i = 0; i < samples.length; i++) {
sum += samples[i] * samples[i];
}
const rms = Math.sqrt(sum / samples.length);
const db = 20 * Math.log10(rms + 0.00001);
const levelText = db > -100 ? `${db.toFixed(1)} dB` : '-∞ dB';
const levelElement = document.getElementById('liveLevel');
if (levelElement) {
levelElement.textContent = `Уровень сигнала: ${levelText}`;
if (db > -12) {
levelElement.style.color = '#ff4757';
} else if (db > -24) {
levelElement.style.color = '#ffa502';
} else {
levelElement.style.color = '#2ed573';
}
}
}
+59 -1
View File
@@ -1,3 +1,4 @@
// frontend/js/modules/smb.js
import { fetchWithAuth } from './api.js';
import { showNotification } from './ui.js';
@@ -16,6 +17,7 @@ export async function loadSMBSettings() {
const smbUsername = document.getElementById('smbUsername');
const smbBasePath = document.getElementById('smbBasePath');
const smbConnectedStatus = document.getElementById('smbConnectedStatus');
const testSmbBtn = document.getElementById('testSmbBtn');
if (smbEnabled) smbEnabled.checked = data.enabled || false;
if (smbAutoStart) smbAutoStart.checked = data.auto_start || false;
@@ -26,6 +28,9 @@ export async function loadSMBSettings() {
if (smbUsername) smbUsername.value = data.username || '';
if (smbBasePath) smbBasePath.value = data.base_path || '';
// Обновляем состояние кнопки в зависимости от наличия параметров
updateTestButtonState();
// Получаем статус подключения
const statusResponse = await fetchWithAuth('/api/smb/status');
if (statusResponse && statusResponse.ok) {
@@ -46,6 +51,27 @@ export async function loadSMBSettings() {
}
}
// Функция для обновления состояния кнопки теста
function updateTestButtonState() {
const testSmbBtn = document.getElementById('testSmbBtn');
const smbServer = document.getElementById('smbServer')?.value || '';
const smbUsername = document.getElementById('smbUsername')?.value || '';
if (testSmbBtn) {
if (smbServer && smbUsername) {
testSmbBtn.disabled = false;
testSmbBtn.style.opacity = '1';
testSmbBtn.style.cursor = 'pointer';
testSmbBtn.title = 'Проверить подключение';
} else {
testSmbBtn.disabled = true;
testSmbBtn.style.opacity = '0.5';
testSmbBtn.style.cursor = 'not-allowed';
testSmbBtn.title = 'Заполните сервер и имя пользователя';
}
}
}
export async function saveSMBSettings() {
const smbEnabled = document.getElementById('smbEnabled')?.checked || false;
const smbAutoStart = document.getElementById('smbAutoStart')?.checked || false;
@@ -93,6 +119,7 @@ export async function saveSMBSettings() {
const pwdField = document.getElementById('smbPassword');
if (pwdField) pwdField.value = '';
await loadSMBSettings();
updateTestButtonState();
} else {
showNotification('Ошибка сохранения настроек SMB', 'error');
}
@@ -103,6 +130,14 @@ export async function saveSMBSettings() {
}
export async function testSMBConnection() {
const smbServer = document.getElementById('smbServer')?.value || '';
const smbUsername = document.getElementById('smbUsername')?.value || '';
if (!smbServer || !smbUsername) {
showNotification('Сначала заполните сервер и имя пользователя', 'warning');
return;
}
showNotification('Проверка подключения...', 'info');
// Сначала сохраняем настройки
@@ -116,8 +151,18 @@ export async function testSMBConnection() {
const status = await response.json();
if (status.connected) {
showNotification('✅ SMB подключен успешно', 'success');
const smbConnectedStatus = document.getElementById('smbConnectedStatus');
if (smbConnectedStatus) {
smbConnectedStatus.innerHTML = '✅ Подключен';
smbConnectedStatus.style.color = '#2ed573';
}
} else {
showNotification('❌ SMB не подключен. Проверьте настройки', 'error');
const smbConnectedStatus = document.getElementById('smbConnectedStatus');
if (smbConnectedStatus) {
smbConnectedStatus.innerHTML = '⏹ Отключен';
smbConnectedStatus.style.color = '#ff4757';
}
}
}
} catch (error) {
@@ -131,4 +176,17 @@ export function toggleSmbPasswordVisibility() {
if (pwdField) {
pwdField.type = pwdField.type === 'password' ? 'text' : 'password';
}
}
}
// Добавляем обработчики событий для полей ввода
document.addEventListener('DOMContentLoaded', () => {
const smbServer = document.getElementById('smbServer');
const smbUsername = document.getElementById('smbUsername');
if (smbServer) {
smbServer.addEventListener('input', updateTestButtonState);
}
if (smbUsername) {
smbUsername.addEventListener('input', updateTestButtonState);
}
});
+1 -12
View File
@@ -4,19 +4,17 @@ import { showNotification } from './ui.js';
let ws = null;
let wsReconnectTimer = null;
let currentChannel = null;
let onWaveformCallback = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;
let pingInterval = null;
export function initWebSocket(callback) {
onWaveformCallback = callback;
// callback больше не используется, но оставляем для совместимости
}
export function connectWebSocket(channelId) {
if (!channelId) return;
// Закрываем существующее соединение
disconnectWebSocket();
currentChannel = channelId;
@@ -31,7 +29,6 @@ export function connectWebSocket(channelId) {
console.log('WebSocket подключен для канала', channelId);
reconnectAttempts = 0;
// Отправляем ping каждые 30 секунд
if (pingInterval) clearInterval(pingInterval);
pingInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
@@ -39,7 +36,6 @@ export function connectWebSocket(channelId) {
}
}, 30000);
// Запрашиваем статус сразу после подключения
setTimeout(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'get_status' }));
@@ -56,14 +52,9 @@ export function connectWebSocket(channelId) {
}
if (data.type === 'status') {
// Обновляем UI статусом с WebSocket
updateUIFromWebSocket(data);
}
if (data.type === 'waveform' && onWaveformCallback) {
onWaveformCallback(data.samples);
}
if (data.type === 'pong') {
// Получен pong, соединение живо
}
@@ -84,7 +75,6 @@ export function connectWebSocket(channelId) {
pingInterval = null;
}
// Переподключение с экспоненциальной задержкой
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
reconnectAttempts++;
@@ -122,7 +112,6 @@ function updateUIFromWebSocket(data) {
}
}
// Обновляем список каналов
const event = new CustomEvent('channelStatusUpdate', {
detail: { channel: currentChannel, recording: data.recording }
});
+135 -8
View File
@@ -12,7 +12,44 @@ body {
min-height: 100vh;
padding: 20px;
}
/* Для совместимости с разными браузерами */
* {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
/* Для select элементов возвращаем стандартный вид на некоторых платформах */
select {
-webkit-appearance: menulist;
-moz-appearance: menulist;
appearance: menulist;
}
/* Для input */
input[type="text"],
input[type="number"],
input[type="password"],
input[type="date"],
input[type="time"] {
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
}
/* Для checkbox */
input[type="checkbox"] {
-webkit-appearance: checkbox;
-moz-appearance: checkbox;
appearance: checkbox;
}
/* Для range slider */
input[type="range"] {
-webkit-appearance: slider-horizontal;
-moz-appearance: slider-horizontal;
appearance: slider-horizontal;
}
.container {
max-width: 1200px;
margin: 0 auto;
@@ -780,14 +817,6 @@ header h1 {
color: white;
}
/* Oscilloscope */
#liveOscilloscope {
width: 100%;
height: 200px;
background: #1a1a2e;
border-radius: 10px;
display: block;
}
/* Info rows */
.info-row {
@@ -1043,4 +1072,102 @@ header h1 {
.btn, .tab-btn, .recording-actions, .batch-actions {
display: none;
}
}
/* SMB кнопка теста */
#testSmbBtn {
transition: all 0.3s ease;
}
#testSmbBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
background: #6c757d !important;
}
#testSmbBtn:not(:disabled) {
opacity: 1;
cursor: pointer;
background: #28a745;
}
#testSmbBtn:not(:disabled):hover {
background: #218838;
transform: translateY(-2px);
}
/* FTP кнопки */
#startFtpBtn:disabled,
#stopFtpBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Общие стили для активных/неактивных кнопок */
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
.btn:not(:disabled) {
opacity: 1;
cursor: pointer;
}
/* Logs */
.logs-list {
background: #1a1a2e;
border-radius: 10px;
padding: 10px;
font-family: 'Courier New', monospace;
font-size: 12px;
}
.log-entry {
padding: 8px 12px;
border-bottom: 1px solid #2a2a3e;
color: #e0e0e0;
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.log-entry:hover {
background: #2a2a3e;
}
.log-time {
color: #888;
min-width: 160px;
}
.log-level {
min-width: 80px;
font-weight: bold;
}
.log-level.INFO {
color: #2ed573;
}
.log-level.SETTINGS {
color: #667eea;
}
.log-level.ERROR {
color: #ff4757;
}
.log-category {
min-width: 100px;
color: #ffa502;
}
.log-message {
flex: 1;
word-break: break-word;
}
.log-details {
color: #888;
font-size: 11px;
}