v 0.0.63 (перефиксация)
This commit is contained in:
Executable → Regular
BIN
Binary file not shown.
@@ -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"}
|
||||
@@ -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
@@ -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
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// Функция для получения текущего выбранного устройства
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user